From bca38814e98b8e177e6c3a3250cfb353623605fd Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:56:25 -0600 Subject: [PATCH 01/22] Add durable owner-configured DateVoteMilestones --- README.md | 2 + .../votingplugin/VotingPluginMain.java | 6 + .../messaging/BackendProxyMessageRouter.java | 2 +- .../votingplugin/commands/CommandLoader.java | 10 +- .../core/datemilestones/DateVoteEvent.java | 60 ++++++ .../core/vote/SharedVoteProcessor.java | 7 +- .../listeners/PlayerVoteListener.java | 5 + .../datemilestones/DateVoteLedger.java | 177 +++++++++++++++++ .../datemilestones/DateVoteMilestones.java | 178 ++++++++++++++++++ .../votingplugin/user/VotingPluginUser.java | 11 +- .../src/main/resources/SpecialRewards.yml | 6 + VotingPlugin/src/main/resources/plugin.yml | 4 + .../BackendProxyMessageRouterTest.java | 31 ++- .../datemilestones/DateVoteEventTest.java | 41 ++++ .../core/vote/SharedVoteProcessorTest.java | 11 ++ .../datemilestones/DateVoteLedgerTest.java | 131 +++++++++++++ .../DateVoteMilestonesTest.java | 163 ++++++++++++++++ .../user/ProxyVoteRealFlagTest.java | 32 ++++ docs/date-vote-milestones.md | 57 ++++++ 19 files changed, 921 insertions(+), 13 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java create mode 100644 docs/date-vote-milestones.md diff --git a/README.md b/README.md index 644944b9dc..bd40a9ba12 100644 --- a/README.md +++ b/README.md @@ -52,3 +52,5 @@ or disabling it; inspections gate disabled state immediately and report a newly ``` `LATEST` resolves to the latest stable release. + +Owner-defined [DateVoteMilestones](docs/date-vote-milestones.md) count qualifying accepted votes within explicit date windows, independently of normal totals. This optional feature is disabled until events are configured. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 9a407073b4..62ae6a865a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -594,6 +594,11 @@ private void loadBungeeHandler() { @Getter private VoteStreakHandler voteStreakHandler; + @Getter + private final com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones dateVoteMilestones = + new com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones(this); + + /** * Registers a directly editable rewards path from SpecialRewards.yml. * @@ -607,6 +612,7 @@ public void loadDirectlyDefined() { VoteSiteRewardRegistrar.register(this); VotePartyRewardRegistrar.register(this); VoteMilestoneRewardRegistrar.register(this); + dateVoteMilestones.reload(); VoteStreakRewardRegistrar.register(this); TopVoterRewardRegistrar.register(this); VoteShopRewardRegistrar.register(this); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index 2cd0ee2818..f85fb68c91 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -484,7 +484,7 @@ private WireVoteResult handleWireVote(JsonEnvelope msg) { user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals, wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown, vote.queuedDeliveryKnown, voteId, - VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel())); + VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote); if (plugin.getBungeeSettings().isPerServerPoints()) { user.addPoints(plugin.getConfigFile().getPointsOnVote()); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index eaabb3511c..cc81759c7b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -3407,7 +3407,15 @@ public void updateReplacements() { */ private void loadVoteCommand() { plugin.setVoteCommand(new ArrayList<>()); - plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" }, + plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "DateEvents" }, + "VotingPlugin.Commands.Vote.DateEvents|" + playerPerm, "View date-window vote milestone progress") { + @Override public void execute(CommandSender sender, String[] args) { + if (sender instanceof Player player) plugin.getDateVoteMilestones().progress(player); + else sender.sendMessage("Run /vote dateevents as a player."); + } + }); + + plugin.getVoteCommand().add(new CommandHandler(plugin, new String[] { "Help&?" }, "VotingPlugin.Commands.Vote.Help|" + playerPerm, "View help page") { @Override diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java new file mode 100644 index 0000000000..e269825db4 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java @@ -0,0 +1,60 @@ +package com.bencodez.votingplugin.core.datemilestones; + +import java.nio.charset.StandardCharsets; +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.security.MessageDigest; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.List; +import java.util.Set; + +/** Immutable owner-defined event accounting contract; display text is not identity. */ +public record DateVoteEvent(String id, String displayName, boolean enabled, long start, long end, + String timezone, List thresholds, Set sites, String accountingServer) { + public DateVoteEvent { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID"); + if (start <= 0 || end <= start) throw new IllegalArgumentException("End must be after start"); + ZoneId.of(timezone); + if (thresholds.isEmpty() || thresholds.size() > 64 || thresholds.stream().anyMatch(n -> n <= 0 || n > 4096) + || thresholds.stream().distinct().count() != thresholds.size()) throw new IllegalArgumentException("Invalid thresholds (1–4096, unique, max 64)"); + thresholds = thresholds.stream().sorted().toList(); + if (sites.size() > 100 || sites.stream().anyMatch(s -> s == null || s.length() > 128 || s.isBlank())) + throw new IllegalArgumentException("Invalid site filters"); + sites = Set.copyOf(sites); + if (accountingServer == null || accountingServer.length() > 128) throw new IllegalArgumentException("Invalid accounting server"); + if (displayName == null || displayName.length() > 256) throw new IllegalArgumentException("Invalid display name"); + } + public boolean matches(long occurredAt, String site, boolean real, boolean proxy, String server) { + return enabled && real && occurredAt >= start && occurredAt < end && (sites.isEmpty() || sites.contains(site)) + && (!proxy || !accountingServer.isBlank() && accountingServer.equals(server)); + } + public String fingerprint() { + try { + var bytes = new ByteArrayOutputStream(); + try (var data = new DataOutputStream(bytes)) { + data.writeLong(start); data.writeLong(end); data.writeUTF(timezone); + data.writeInt(thresholds.size()); + for (int threshold : thresholds) data.writeInt(threshold); + data.writeInt(sites.size()); + for (String site : sites.stream().sorted().toList()) data.writeUTF(site); + data.writeUTF(accountingServer); + } + return hash(bytes.toByteArray()); + } catch (java.io.IOException impossible) { throw new IllegalStateException(impossible); } + } + /** Portable case-sensitive identity, including on case-insensitive filesystems. */ + public String fileId() { return hash(id.getBytes(StandardCharsets.UTF_8)); } + private static String hash(byte[] bytes) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + } + public static long timestamp(String localIso, String timezone) { + LocalDateTime time = LocalDateTime.parse(localIso); + ZoneId zone = ZoneId.of(timezone); + if (zone.getRules().getValidOffsets(time).size() != 1) + throw new IllegalArgumentException("Ambiguous/nonexistent local time; choose an unambiguous boundary"); + return time.atZone(zone).toInstant().toEpochMilli(); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index c64b20f13d..bb373a8c02 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -1,7 +1,5 @@ package com.bencodez.votingplugin.core.vote; -import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.UUID; /** The accepted vote sequence. Platform operations are supplied by the receiving adapter. */ @@ -67,6 +65,7 @@ public interface Operations { boolean forceProxyRouting(); boolean wasOnline(); boolean realVote(); + default long currentTimeMillis() { return System.currentTimeMillis(); } boolean addTotals(); boolean broadcastEnabled(); boolean hasBroadcastHandler(); @@ -105,6 +104,7 @@ public interface Operations { int enabledSiteCount(); void setMonthTotal(U user, int total); void milestones(U user, UUID voteId, boolean forceProxyRouting); + default void dateMilestones(U user, S site, long voteTime, UUID voteId) { } void cooldown(U user, S site); void voteStreak(U user, long voteTime, UUID voteId); void postVote(S site, U user, String playerName, long voteTime, UUID voteId, boolean cached); @@ -217,7 +217,7 @@ public static void process(Operations ops) { voteTime = ops.incomingTime(); } else { ops.setTimeNow(user, site); - voteTime = LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + voteTime = ops.currentTimeMillis(); } boolean cached = false; if (SharedVoteDelivery.shouldDeliverNow(ops::proxyVote, () -> ops.userOnline(user), @@ -251,6 +251,7 @@ public static void process(Operations ops) { } } ops.milestones(user, voteId, ops.forceProxyRouting()); + ops.dateMilestones(user, site, voteTime, voteId); ops.cooldown(user, site); ops.voteStreak(user, voteTime, voteId); ops.postVote(site, user, playerName, voteTime, voteId, cached); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 6b4d816681..437c766a3e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -173,6 +173,11 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { plugin.getVoteMilestonesManager().handleVote(user, event.getBungeeTextTotals(), forceProxyRouting, voteId, new HashMap()); } + @Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) { + if (!event.isAsynchronous()) return; // No persistence from a gameplay event context. + plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, voteTime, realVote(), proxyVote(), + proxyVoteId() != null && incomingTime() > 0, targetedProxyVote(), forceProxyRouting()); + } @Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); } @Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) { plugin.getVoteStreakHandler().processVote(user, voteTime, voteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java new file mode 100644 index 0000000000..6a62996da1 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java @@ -0,0 +1,177 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import com.bencodez.votingplugin.util.DurableFiles; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; + +/** Single local accounting owner. All entry points must run on a persistence worker. */ +public final class DateVoteLedger { + public record Progress(int votes, Set reservedAwards, Set submittedAwards) { } + private final Path directory; + private int recordCount = -1; + public DateVoteLedger(Path directory) { this.directory = directory.toAbsolutePath().normalize(); } + + public synchronized List record(DateVoteEvent event, UUID player, UUID occurrence) throws IOException { + seal(event); + Path file = playerFile(event, player); + Properties state = read(file); + String fingerprint = state.getProperty("fingerprint"); + if (fingerprint != null && !fingerprint.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new event ID"); + validateFingerprint(event, state); + Set seen = occurrences(state); + if (seen.contains(occurrence)) return List.of(); + if (seen.size() >= 4096) throw new IOException("Event occurrence capacity reached (4096); progress retained"); + seen.add(occurrence); + state.setProperty("fingerprint", event.fingerprint()); + state.setProperty("seen", String.join(",", seen.stream().map(UUID::toString).sorted().toList())); + List awards = new ArrayList<>(); + for (int threshold : event.thresholds()) { + String key = "award." + threshold; + if (seen.size() >= threshold && !state.containsKey(key)) { + state.setProperty(key, "RESERVED"); awards.add(threshold); + } + } + // Reserve the whole count/award decision before invoking arbitrary external rewards. + write(file, state); + return List.copyOf(awards); + } + public synchronized void submitted(DateVoteEvent event, UUID player, int threshold) throws IOException { + Path file = playerFile(event, player); + Properties state = read(file); + validateFingerprint(event, state); + if (!"RESERVED".equals(state.getProperty("award." + threshold))) return; + state.setProperty("award." + threshold, "SUBMITTED"); + write(file, state); + } + public synchronized Progress progress(DateVoteEvent event, UUID player) throws IOException { + Properties definitions = readSealedDefinitions(event); + String sealed = definitions.getProperty(event.id()); + if (sealed != null && !sealed.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new ID"); + Properties state = read(playerFile(event, player)); + validateFingerprint(event, state); + Set reserved = new HashSet<>(), submitted = new HashSet<>(); + for (String key : state.stringPropertyNames()) { + if (key.startsWith("award.")) { + int threshold; + try { threshold = Integer.parseInt(key.substring(6)); } + catch (NumberFormatException invalid) { throw new IOException("Invalid award state", invalid); } + if ("RESERVED".equals(state.getProperty(key))) reserved.add(threshold); + else if ("SUBMITTED".equals(state.getProperty(key))) submitted.add(threshold); + else throw new IOException("Unknown award state"); + } + } + return new Progress(occurrences(state).size(), Set.copyOf(reserved), Set.copyOf(submitted)); + } + private void validateFingerprint(DateVoteEvent event, Properties state) throws IOException { + if (state.isEmpty()) return; + if (!event.fingerprint().equals(state.getProperty("fingerprint"))) + throw new IOException("Event accounting changed or state invalid; use a new ID"); + int votes = occurrences(state).size(); + for (int threshold : event.thresholds()) if (votes >= threshold && !state.containsKey("award." + threshold)) + throw new IOException("Missing reached milestone award state; progress retained for review"); + for (String key : state.stringPropertyNames()) if (key.startsWith("award.") + && !event.thresholds().contains(Integer.parseInt(key.substring(6)))) + throw new IOException("Award is outside configured milestone contract"); + } + private void seal(DateVoteEvent event) throws IOException { + Properties definitions = readSealedDefinitions(event); + String old = definitions.getProperty(event.id()); + if (old != null && !old.equals(event.fingerprint())) throw new IOException("DateVoteMilestones event " + event.id() + " changed accounting; use a new ID"); + if (old == null) { + if (definitions.size() >= 1024) throw new IOException("Event definition capacity reached"); + definitions.setProperty(event.id(), event.fingerprint()); + write(directory.resolve("definitions.properties"), definitions); + } + } + /** Missing metadata cannot turn surviving player history into a fresh event. */ + private Properties readSealedDefinitions(DateVoteEvent event) throws IOException { + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + Properties definitions = read(directory.resolve("definitions.properties")); + if (!definitions.containsKey(event.id()) && Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) { + // Bounded accounting directory; this lookup is exclusively on the I/O owner. + try (var history = Files.newDirectoryStream(directory, event.fileId() + "-*.properties")) { + if (history.iterator().hasNext()) + throw new IOException("Missing historical event seal; preserve state and repair metadata before accounting"); + } + } + return definitions; + } + private Path playerFile(DateVoteEvent event, UUID player) { + return directory.resolve(event.fileId() + "-" + player + ".properties"); + } + private Set occurrences(Properties state) throws IOException { + Set ids = new HashSet<>(); + String raw = state.getProperty("seen", ""); + if (!raw.isEmpty()) for (String id : raw.split(",", -1)) { + try { if (!ids.add(UUID.fromString(id))) throw new IOException("Duplicate stored occurrence"); } + catch (IllegalArgumentException invalid) { throw new IOException("Invalid stored occurrence", invalid); } + if (ids.size() > 4096) throw new IOException("Oversized event state"); + } + return ids; + } + private Properties read(Path file) throws IOException { + Properties state = new Properties() { + @Override public synchronized Object put(Object key, Object value) { + if (containsKey(key)) throw new IllegalArgumentException("Duplicate stored field"); + return super.put(key, value); + } + }; + if (Files.notExists(file, LinkOption.NOFOLLOW_LINKS)) return new Properties(); + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + if (Files.isSymbolicLink(file) || !Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS) || Files.size(file) > 256 * 1024) + throw new IOException("Unsafe/oversized date milestone state"); + try { state.load(new StringReader(Files.readString(file))); } + catch (IllegalArgumentException invalid) { throw new IOException("Malformed milestone state", invalid); } + if (file.getFileName().toString().equals("definitions.properties")) { + if (state.isEmpty() || state.size() > 1024) throw new IOException("Empty/oversized definitions; existing seals must not be reset"); + for (String key : state.stringPropertyNames()) if (!key.matches("[A-Za-z0-9_-]{1,64}") + || !state.getProperty(key).matches("[a-f0-9]{64}")) throw new IOException("Invalid definition state"); + } else { + if (!state.containsKey("fingerprint") || !state.getProperty("fingerprint").matches("[a-f0-9]{64}") + || !state.containsKey("seen") || state.size() > 66) throw new IOException("Incomplete milestone state"); + Set seen = occurrences(state); + for (String key : state.stringPropertyNames()) if (!key.equals("fingerprint") && !key.equals("seen")) { + if (!key.matches("award\\.[1-9][0-9]{0,3}")) throw new IOException("Unknown milestone state field"); + int threshold = Integer.parseInt(key.substring(6)); + if (threshold > 4096 || threshold > seen.size() + || !(state.getProperty(key).equals("RESERVED") || state.getProperty(key).equals("SUBMITTED"))) + throw new IOException("Invalid award state"); + } + } + Properties mutable = new Properties(); mutable.putAll(state); + return mutable; + } + protected void write(Path file, Properties state) throws IOException { + if (Files.isSymbolicLink(directory)) throw new IOException("Unsafe milestone directory"); + Files.createDirectories(directory); + if (Files.isSymbolicLink(directory) || Files.isSymbolicLink(file)) throw new IOException("Unsafe milestone state path"); + if (recordCount < 0) { + recordCount = 0; + try (var files = Files.newDirectoryStream(directory, "*.properties")) { + for (Path existing : files) if (!existing.getFileName().toString().equals("definitions.properties")) { + if (++recordCount > 100_000) throw new IOException("Milestone record capacity exceeded"); + } + } catch (IOException failure) { recordCount = -1; throw failure; } + } + boolean newRecord = !file.getFileName().toString().equals("definitions.properties") && Files.notExists(file, LinkOption.NOFOLLOW_LINKS); + if (newRecord && recordCount >= 100_000) throw new IOException("Milestone record capacity reached; existing progress retained"); + StringWriter writer = new StringWriter(); state.store(writer, "DateVoteMilestones v1"); + String text = writer.toString(); + if (text.length() > 256 * 1024) throw new IOException("Oversized milestone state"); + Path staged = Files.createTempFile(directory, "milestone-", ".tmp"); + try { Files.writeString(staged, text); DurableFiles.publishStagedFile(staged, file); if (newRecord) recordCount++; } + catch (IOException failure) { recordCount = -1; throw failure; } + finally { Files.deleteIfExists(staged); } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java new file mode 100644 index 0000000000..bd9f80aca4 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -0,0 +1,178 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; + +/** Owner-defined windows, independently accounted on one backend using original occurrence IDs. */ +public final class DateVoteMilestones { + private record Definition(DateVoteEvent event, YamlConfiguration rewards) { } + private final VotingPluginMain plugin; + private volatile List definitions = List.of(); + private final java.util.Set pendingProgress = new java.util.HashSet<>(); + private DateVoteLedger ledger; + public DateVoteMilestones(VotingPluginMain plugin) { this.plugin = plugin; } + private synchronized DateVoteLedger ledger() { + if (ledger == null) ledger = new DateVoteLedger(plugin.getDataFolder().toPath().resolve("date-vote-milestones")); + return ledger; + } + /** Configuration is already loaded; this method performs no disk access. */ + public void reload() { + List loaded = new ArrayList<>(); + YamlConfiguration rewards = new YamlConfiguration(); + var root = plugin.getSpecialRewardsConfig().getData().getConfigurationSection("DateVoteMilestones"); + if (root != null && !root.getKeys(false).isEmpty()) { + try { rewards.loadFromString(plugin.getSpecialRewardsConfig().getData().saveToString()); } + catch (org.bukkit.configuration.InvalidConfigurationException invalid) { + definitions = List.of(); plugin.getLogger().warning("DateVoteMilestones configuration snapshot invalid"); return; + } + } + if (root != null) for (String id : root.getKeys(false)) { + if (loaded.size() >= 64) { plugin.getLogger().warning("DateVoteMilestones supports at most 64 configured events"); break; } + try { + ConfigurationSection section = root.getConfigurationSection(id); + if (section == null) throw new IllegalArgumentException("Expected an event section"); + DateVoteEvent event = parse(id, section); + if (plugin.getBungeeSettings().isUseBungeecoord() && event.accountingServer().isBlank()) + throw new IllegalArgumentException("Proxy events require one explicit AccountingServer and all-server identified delivery"); + loaded.add(new Definition(event, rewards)); + // Disabled events retain reward handles for previously queued awards. + for (int threshold : event.thresholds()) { + String source = configPath(event, threshold), alias = path(event, threshold); + rewards.createSection(alias, rewards.getConfigurationSection(source).getValues(true)); + plugin.addDirectlyDefinedRewards(new DirectlyDefinedReward(alias) { + private String sourcePath(String requested) { + if (!requested.equals(alias) && !requested.startsWith(alias + ".")) + throw new IllegalArgumentException("Unexpected date milestone reward path"); + return source + requested.substring(alias.length()); + } + @Override public ConfigurationSection getFileData() { + YamlConfiguration data = new YamlConfiguration(); + var section = plugin.getSpecialRewardsConfig().getData().getConfigurationSection(source); + if (section != null) data.createSection(alias, section.getValues(true)); + return data; + } + @Override public void createSection(String key) { plugin.getSpecialRewardsConfig().createSection(sourcePath(key)); } + @Override public void setData(String key, Object value) { plugin.getSpecialRewardsConfig().setValue(sourcePath(key), value); } + @Override public void save() { plugin.getSpecialRewardsConfig().saveData(); } + }); + } + } catch (Exception invalid) { + plugin.getLogger().warning("DateVoteMilestones " + id + " disabled: " + invalid.getMessage()); + } + } + definitions = List.copyOf(loaded); + } + public static DateVoteEvent parse(String id, ConfigurationSection section) { + String zone = section.getString("Timezone"); + String start = section.getString("Start"), end = section.getString("End"); + var milestones = section.getConfigurationSection("Milestones"); + if (zone == null || start == null || end == null || milestones == null) + throw new IllegalArgumentException("Start, End, Timezone and Milestones are required"); + List thresholds = new ArrayList<>(); + for (String key : milestones.getKeys(false)) { + int threshold = Integer.parseInt(key); + if (!key.equals(Integer.toString(threshold)) || milestones.getConfigurationSection(key + ".Rewards") == null) + throw new IllegalArgumentException("Each numeric milestone must contain Rewards"); + thresholds.add(threshold); + } + return new DateVoteEvent(id, section.getString("DisplayName", id), section.getBoolean("Enabled", false), + DateVoteEvent.timestamp(start, zone), DateVoteEvent.timestamp(end, zone), zone, thresholds, + new java.util.HashSet<>(section.getStringList("VoteSites")), section.getString("AccountingServer", "")); + } + static String path(DateVoteEvent event, int threshold) { + return "DateVoteMilestonesRuntime." + event.fileId() + ".Milestones." + threshold + ".Rewards"; + } + private static String configPath(DateVoteEvent event, int threshold) { + return "DateVoteMilestones." + event.id() + ".Milestones." + threshold + ".Rewards"; + } + /** Called only from the real asynchronous accepted-vote pipeline; never from navigation. */ + public void accepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, + boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) { + if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return; + if (!real || occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return; + for (Definition definition : definitions) { + DateVoteEvent event = definition.event(); + if (!event.matches(occurredAt, site, real, proxy, plugin.getBungeeSettings().getServer())) continue; + try { + for (int threshold : ledger().record(event, user.getJavaUUID(), occurrence)) { + var placeholders = new HashMap(); + placeholders.put("DateVoteEvent", event.displayName()); + placeholders.put("DateVoteThreshold", Integer.toString(threshold)); + plugin.getRewardHandler().giveReward(user, definition.rewards(), path(event, threshold), + new RewardOptions().setPrefix("DateVoteMilestones-" + event.id() + "-" + threshold) + .setServer(forceProxyRouting).setPlaceholders(placeholders)); + // Submission is not a transactional guarantee of arbitrary command execution. + ledger().submitted(event, user.getJavaUUID(), threshold); + } + } catch (IOException | RuntimeException failure) { + plugin.getLogger().warning("DateVoteMilestones " + event.id() + " accounting/reward submission failed; progress retained for review: " + failure.getMessage()); + } + } + } + public void progress(Player player) { + BukkitCompletionScheduler.run(plugin, player, () -> { + UUID uuid = player.getUniqueId(); + String playerName = player.getName(); + if (!player.hasPermission("VotingPlugin.Commands.Vote.DateEvents") && !player.hasPermission("VotingPlugin.Player")) return; + synchronized (pendingProgress) { + if (pendingProgress.contains(uuid) || pendingProgress.size() >= 64) { + player.sendMessage("Date voting progress is already being checked or busy. Try again shortly."); return; + } + pendingProgress.add(uuid); + } + List snapshot = definitions; + try { + plugin.getTimer().execute(() -> { + try { + if (snapshot != definitions || !plugin.isEnabled()) return; + UUID storageId = snapshot.isEmpty() ? uuid + : plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, playerName).getJavaUUID(); + List messages = new ArrayList<>(); + if (snapshot.stream().noneMatch(d -> d.event().enabled())) messages.add("No date voting events are enabled."); + for (Definition definition : snapshot) { + var event = definition.event(); + if (!event.enabled()) continue; + if (plugin.getBungeeSettings().isUseBungeecoord() && !event.accountingServer().equals(plugin.getBungeeSettings().getServer())) { + messages.add(event.displayName() + ": progress is owned by backend " + event.accountingServer()); continue; + } + try { + var progress = ledger().progress(event, storageId); + long now = System.currentTimeMillis(); + String state = now < event.start() ? "upcoming" : now >= event.end() ? "ended" : "active"; + messages.add(event.displayName() + " (" + state + "): " + progress.votes() + " votes; milestones " + event.thresholds() + + "; submitted " + progress.submittedAwards() + "; pending review " + progress.reservedAwards()); + } catch (IOException failure) { messages.add(event.displayName() + ": progress unavailable; ask an administrator."); } + } + BukkitCompletionScheduler.run(plugin, player, () -> { + if (player.isOnline() && plugin.isEnabled() && snapshot == definitions + && (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents") || player.hasPermission("VotingPlugin.Player"))) + messages.forEach(player::sendMessage); + }, () -> { }, () -> { }); + } catch (RuntimeException unavailable) { + BukkitCompletionScheduler.run(plugin, player, () -> { + if (player.isOnline() && plugin.isEnabled() && snapshot == definitions + && (player.hasPermission("VotingPlugin.Commands.Vote.DateEvents") || player.hasPermission("VotingPlugin.Player"))) + player.sendMessage("Date voting progress is temporarily unavailable."); + }, () -> { }, () -> { }); + } finally { synchronized (pendingProgress) { pendingProgress.remove(uuid); } } + }); + } catch (RejectedExecutionException unavailable) { + synchronized (pendingProgress) { pendingProgress.remove(uuid); } + player.sendMessage("Date voting progress is temporarily unavailable."); + } + }, () -> { }, () -> { }); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 8faa07a809..a59383901c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -979,11 +979,20 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId, boolean targetedProxyVote) { + bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote, + proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, true); + } + + /** Preserve the canonical wire real/test flag; existing callers retain their historical real-vote default. */ + public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, + boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, + boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId, + boolean targetedProxyVote, boolean realVote) { if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true), - getPlayerName(), service, true); + getPlayerName(), service, realVote); voteEvent.setBungee(true); voteEvent.setVotingPluginUser(this); voteEvent.setForceBungee(true); diff --git a/VotingPlugin/src/main/resources/SpecialRewards.yml b/VotingPlugin/src/main/resources/SpecialRewards.yml index 6b48ca4db0..0fa7017da6 100644 --- a/VotingPlugin/src/main/resources/SpecialRewards.yml +++ b/VotingPlugin/src/main/resources/SpecialRewards.yml @@ -397,3 +397,9 @@ DailyAwards: Rewards: Messages: Player: '&aYou came in second place in %TopVoter%!' + +# Owner-defined dated events. Opt-in; use a NEW event ID when changing accounting. +# Explicit local ISO start/end + IANA timezone. Start inclusive, end exclusive. +# Proxy use requires one AccountingServer and identified all-server delivery. +# Full configuration example and durability limits: docs/date-vote-milestones.md +DateVoteMilestones: {} diff --git a/VotingPlugin/src/main/resources/plugin.yml b/VotingPlugin/src/main/resources/plugin.yml index 633dc78417..b7448241f9 100644 --- a/VotingPlugin/src/main/resources/plugin.yml +++ b/VotingPlugin/src/main/resources/plugin.yml @@ -195,3 +195,7 @@ permissions: VotingPlugin.Login.RemindVotes.All: description: Remind if player can vote on all votesites, rather than any default: false + + VotingPlugin.Commands.Vote.DateEvents: + description: View owner-configured date event progress + default: false diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index b4ba67b234..dd57bed3d4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -191,7 +191,7 @@ void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() { .thenReturn(user); doThrow(new IllegalStateException("partial reward")).when(user).bungeeVotePluginMessaging( any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), - anyBoolean(), any(), anyBoolean()); + anyBoolean(), any(), anyBoolean(), anyBoolean()); BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); @@ -237,7 +237,7 @@ void reliableVoteRetriesReceiptPersistenceWithoutRepeatingEffectsInProcess() { assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); verify(cache, times(2)).complete(voteId); verify(user, times(1)).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false)); + anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false), anyBoolean()); } @Test @@ -258,7 +258,24 @@ void stableVoteIdAloneDoesNotBypassBackendDelayValidation() { LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false)); + anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false), anyBoolean()); + } + + @Test + void canonicalTestVoteFlagIsPassedToTheBackendEventAdapter() { + UUID voteId = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveWithOutcome(voteId)).thenReturn(Reservation.RESERVED); + when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user); + when(plugin.getServerData()).thenReturn(mock(ServerData.class)); + BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, + mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), + mock(BackendVotePartySync.class), cache); + voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", + LAST_VOTE_TIME, true, false, "", voteId, false, false, 1, 1), ignored -> { }); + verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(false), eq(false)); } @Test @@ -278,7 +295,7 @@ void voteOnlineMarksTheDeliveryAsTargetedToThisBackend() { LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true)); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true), anyBoolean()); } @Test @@ -300,7 +317,7 @@ void malformedReliableVoteIsQuarantinedWithoutCompletionReceipt() { assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); verify(cache, never()).complete(voteId); verify(user, never()).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean()); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); verify(cache).cancelReservation(voteId); } @@ -320,7 +337,7 @@ void saturatedReliableVoteRequestsRetryWithoutRunningEffects() { assertEquals(OrderedVoteOutcome.RETRY, outcome.get()); verify(user, never()).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean()); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); verify(cache, never()).complete(voteId); } @@ -452,7 +469,7 @@ void explicitQueuedDeliveryIsPassedSeparatelyFromDelayValidation() { voteRouter.handleOrderedVote(queued, ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true)); + anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true), anyBoolean()); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java new file mode 100644 index 0000000000..94cb7823ff --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEventTest.java @@ -0,0 +1,41 @@ +package com.bencodez.votingplugin.core.datemilestones; +import static org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +class DateVoteEventTest { + private DateVoteEvent event(String name, long end) { + return new DateVoteEvent("october", name, true, 100, end, "UTC", List.of(25, 10), Set.of("a"), "owner"); + } + @Test void boundariesFiltersAndNetworkOwnerAreExplicit() { + var e = event("October", 200); + assertFalse(e.matches(99, "a", true, false, "")); + assertTrue(e.matches(100, "a", true, false, "")); + assertTrue(e.matches(199, "a", true, true, "owner")); + assertFalse(e.matches(200, "a", true, true, "owner")); + assertFalse(e.matches(100, "b", true, false, "")); + assertFalse(e.matches(100, "a", false, false, "")); + assertFalse(e.matches(100, "a", true, true, "other")); + } + @Test void displayRenamePreservesIdentityButWindowChangeDoesNot() { + assertEquals(event("October", 200).fingerprint(), event("Renamed", 200).fingerprint()); + assertNotEquals(event("October", 200).fingerprint(), event("October", 201).fingerprint()); + } + @Test void timezoneAndDstBoundariesRejectAmbiguity() { + assertEquals(3600000, DateVoteEvent.timestamp("2026-10-01T00:00:00", "UTC") + - DateVoteEvent.timestamp("2026-10-01T00:00:00", "Europe/London")); + assertThrows(IllegalArgumentException.class, () -> DateVoteEvent.timestamp("2026-11-01T01:30:00", "America/New_York")); + assertThrows(IllegalArgumentException.class, () -> DateVoteEvent.timestamp("2026-03-08T02:30:00", "America/New_York")); + assertThrows(RuntimeException.class, () -> DateVoteEvent.timestamp("not-a-date", "UTC")); + } + @Test void siteFilterFingerprintUsesUnambiguousEncoding() { + var first = new DateVoteEvent("a", "A", true, 100, 200, "UTC", List.of(1), Set.of("a", "b"), ""); + var second = new DateVoteEvent("a", "A", true, 100, 200, "UTC", List.of(1), Set.of("a, b"), ""); + assertNotEquals(first.fingerprint(), second.fingerprint()); + } + @Test void invalidIdentityWindowAndThresholdsAreRejected() { + assertThrows(IllegalArgumentException.class, () -> new DateVoteEvent("../x", "x", true, 100, 200, "UTC", List.of(1), Set.of(), "")); + assertThrows(IllegalArgumentException.class, () -> new DateVoteEvent("x", "x", true, 100, 100, "UTC", List.of(1), Set.of(), "")); + assertThrows(IllegalArgumentException.class, () -> new DateVoteEvent("x", "x", true, 100, 200, "UTC", List.of(1,1), Set.of(), "")); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index d0fb241ebb..c5f10a73d9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -75,6 +75,7 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).checkDayVoteStreak(user, false); order.verify(ops).setMonthTotal(user, 2); order.verify(ops).milestones(eq(user), any(UUID.class), eq(false)); + order.verify(ops).dateMilestones(eq(user), eq(site), eq(123L), any(UUID.class)); order.verify(ops).cooldown(user, site); order.verify(ops).voteStreak(eq(user), eq(123L), any(UUID.class)); order.verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(123L), any(UUID.class), eq(false)); @@ -82,6 +83,15 @@ void onlineVoteKeepsProductionPhaseOrderAndCounts() { order.verify(ops).setUpdate(); } + @Test + void standaloneDateMembershipUsesActualInstantDuringDstRollback() { + var ops = accepted(); + long instant = java.time.Instant.parse("2026-11-01T06:15:00Z").toEpochMilli(); + when(ops.currentTimeMillis()).thenReturn(instant); + SharedVoteProcessor.process(ops); + verify(ops).dateMilestones(eq(user), eq(site), eq(instant), any(UUID.class)); + } + @Test void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { var ops = accepted(); @@ -108,6 +118,7 @@ void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { ArgumentCaptor id = ArgumentCaptor.forClass(UUID.class); verify(ops).postVote(eq(site), eq(user), eq("Ben"), eq(321L), id.capture(), eq(false)); assertEquals(proxyId, id.getValue()); + verify(ops).dateMilestones(user, site, 321L, proxyId); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java new file mode 100644 index 0000000000..a082740696 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java @@ -0,0 +1,131 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; +import static org.junit.jupiter.api.Assertions.*; +import com.bencodez.votingplugin.core.datemilestones.DateVoteEvent; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.Executors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +class DateVoteLedgerTest { + @TempDir Path root; + final UUID player = UUID.randomUUID(); + private DateVoteEvent event(String id, String name, long end) { + return new DateVoteEvent(id, name, true, 100, end, "UTC", List.of(1, 2), Set.of(), ""); + } + @Test void duplicateAndRestartNeverResubmitReservedOrSubmittedAwards() throws Exception { + var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); UUID id = UUID.randomUUID(); + assertEquals(List.of(1), ledger.record(e, player, id)); + ledger = new DateVoteLedger(root); + assertEquals(List.of(), ledger.record(e, player, id)); + assertEquals(Set.of(1), ledger.progress(e, player).reservedAwards()); + ledger.submitted(e, player, 1); ledger.submitted(e, player, 1); + ledger = new DateVoteLedger(root); + assertEquals(List.of(2), ledger.record(e, player, UUID.randomUUID())); + assertEquals(Set.of(1), ledger.progress(e, player).submittedAwards()); + assertEquals(2, ledger.progress(e, player).votes()); + } + @Test void overlapsCountOnceForEachEventButUnrelatedPlayersAreSeparate() throws Exception { + var ledger = new DateVoteLedger(root); UUID occurrence = UUID.randomUUID(); + assertEquals(List.of(1), ledger.record(event("a", "A", 200), player, occurrence)); + assertEquals(List.of(1), ledger.record(event("b", "B", 300), player, occurrence)); + assertEquals(List.of(1), ledger.record(event("a", "A", 200), UUID.randomUUID(), occurrence)); + } + @Test void sameOccurrenceConcurrentRequestsReserveExactlyOnce() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); UUID occurrence = UUID.randomUUID(); + try (var pool = Executors.newFixedThreadPool(4)) { + var jobs = java.util.stream.IntStream.range(0, 20).mapToObj(i -> (java.util.concurrent.Callable>) + () -> ledger.record(e, player, occurrence)).toList(); + int awarded = 0; + for (var result : pool.invokeAll(jobs)) awarded += result.get().size(); + assertEquals(1, awarded); assertEquals(1, ledger.progress(e, player).votes()); + } + } + @Test void immutableContractIsSealedAcrossPlayersAndDisplayRenames() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); + ledger.record(e, player, UUID.randomUUID()); + assertEquals(1, ledger.progress(event("a", "Renamed", 200), player).votes()); + assertThrows(IOException.class, () -> ledger.record(event("a", "A", 300), UUID.randomUUID(), UUID.randomUUID())); + assertThrows(IOException.class, () -> ledger.progress(event("a", "A", 300), player)); + } + @Test void malformedStateFailsClosedWithoutResettingOrRewards() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); + ledger.record(e, player, UUID.randomUUID()); + Path file = root.resolve(e.fileId() + "-" + player + ".properties"); + String original = Files.readString(file); + for (String corruption : List.of("", "seen=\nfingerprint=bad\n", original + "seen=\n", original + "unknown=value\n")) { + Files.writeString(file, corruption); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(e, player, UUID.randomUUID())); + assertEquals(corruption, Files.readString(file)); + } + } + @Test void missingReachedAwardFailsClosedForReadsWritesAndAcknowledgements() throws Exception { + var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); + ledger.record(e, player, UUID.randomUUID()); ledger.submitted(e, player, 1); + Path file = root.resolve(e.fileId() + "-" + player + ".properties"); + String broken = Files.readString(file).replace("award.1=SUBMITTED\n", ""); + Files.writeString(file, broken); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(e, player, UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(e, player)); + assertThrows(IOException.class, () -> new DateVoteLedger(root).submitted(e, player, 1)); + assertEquals(broken, Files.readString(file)); + } + @Test void caseDistinctIdsHaveDistinctPortableFilesAndIndependentProgress() throws Exception { + var first = event("Festival", "First", 200); var second = event("festival", "Second", 200); + var ledger = new DateVoteLedger(root); UUID occurrence = UUID.randomUUID(); + assertNotEquals(first.fileId().toLowerCase(), second.fileId().toLowerCase()); + assertEquals(List.of(1), ledger.record(first, player, occurrence)); + assertEquals(List.of(1), ledger.record(second, player, occurrence)); + assertEquals(1, new DateVoteLedger(root).progress(first, player).votes()); + assertEquals(1, new DateVoteLedger(root).progress(second, player).votes()); + } + @Test void delimiterContainingFilterCannotChangeASealedContract() throws Exception { + var a = new DateVoteEvent("event", "A", true, 100, 200, "UTC", List.of(1), Set.of("a", "b"), ""); + var b = new DateVoteEvent("event", "B", true, 100, 200, "UTC", List.of(1), Set.of("a, b"), ""); + var ledger = new DateVoteLedger(root); ledger.record(a, player, UUID.randomUUID()); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(b, player, UUID.randomUUID())); + } + @Test void truncatedDefinitionsCannotResetSealedContractsForNewPlayers() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); + ledger.record(e, player, UUID.randomUUID()); + Path definitions = root.resolve("definitions.properties"); Files.writeString(definitions, ""); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(event("a", "A", 300), UUID.randomUUID(), UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(e, player)); + assertEquals("", Files.readString(definitions)); + } + @Test void missingDefinitionsCannotResealSurvivingHistoryForANewPlayer() throws Exception { + var ledger = new DateVoteLedger(root); var original = event("a", "A", 200); + ledger.record(original, player, UUID.randomUUID()); + Files.delete(root.resolve("definitions.properties")); + var changed = event("a", "A", 300); UUID newcomer = UUID.randomUUID(); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(changed, newcomer, UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(changed, newcomer)); + assertFalse(Files.exists(root.resolve("definitions.properties"))); + assertFalse(Files.exists(root.resolve(changed.fileId() + "-" + newcomer + ".properties"))); + } + @Test void partialDefinitionsCannotResealAnOmittedHistoricalEvent() throws Exception { + var ledger = new DateVoteLedger(root); var first = event("a", "A", 200); var second = event("b", "B", 200); + ledger.record(first, player, UUID.randomUUID()); ledger.record(second, player, UUID.randomUUID()); + Path definitions = root.resolve("definitions.properties"); + String partial = Files.readString(definitions).replace("a=" + first.fingerprint() + "\n", ""); + Files.writeString(definitions, partial); + assertThrows(IOException.class, () -> new DateVoteLedger(root).record(event("a", "A", 300), UUID.randomUUID(), UUID.randomUUID())); + assertThrows(IOException.class, () -> new DateVoteLedger(root).progress(first, UUID.randomUUID())); + assertEquals(partial, Files.readString(definitions)); + assertEquals(1, new DateVoteLedger(root).progress(second, player).votes()); + } + @Test void newIdsWithNoHistoryRemainUsableAlongsideSealedEvents() throws Exception { + var ledger = new DateVoteLedger(root); ledger.record(event("a", "A", 200), player, UUID.randomUUID()); + assertEquals(List.of(1), new DateVoteLedger(root).record(event("new", "New", 300), player, UUID.randomUUID())); + } + @Test void unsafeDirectoryAndUnavailableStorageNeverAdmitAwards() throws Exception { + Path file = root.resolve("file"); Files.writeString(file, "keep"); + assertThrows(IOException.class, () -> new DateVoteLedger(file).record(event("a", "A", 200), player, UUID.randomUUID())); + Path link = root.resolve("link"); Files.createSymbolicLink(link, root); + assertThrows(IOException.class, () -> new DateVoteLedger(link).record(event("a", "A", 200), player, UUID.randomUUID())); + assertEquals("keep", Files.readString(file)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java new file mode 100644 index 0000000000..26680196f2 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -0,0 +1,163 @@ +package com.bencodez.votingplugin.specialrewards.datemilestones; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +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.user.VotingPluginUser; +class DateVoteMilestonesTest { + @TempDir Path root; + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final VotingPluginUser user = mock(VotingPluginUser.class); + final YamlConfiguration config = new YamlConfiguration(); + final UUID player = UUID.randomUUID(); + final long time = java.time.Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); + private void event(String id, String owner) { + String key = "DateVoteMilestones." + id + "."; + config.set(key + "Enabled", true); config.set(key + "DisplayName", id); + config.set(key + "Start", "2026-10-01T00:00:00"); config.set(key + "End", "2026-11-01T00:00:00"); + config.set(key + "Timezone", "UTC"); config.set(key + "AccountingServer", owner); + config.set(key + "Milestones.1.Rewards.Messages.Player", "Thanks"); + } + private DateVoteMilestones manager() { + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); + when(user.getJavaUUID()).thenReturn(player); + var m = new DateVoteMilestones(plugin); m.reload(); return m; + } + @Test void configuredRewardsUseExistingHandlerOnceAcrossReplayRestartAndRename() { + event("october", ""); var m = manager(); UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + config.set("DateVoteMilestones.october.DisplayName", "Renamed"); + m = manager(); m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + verify(user, never()).addTotal(); + verify(user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + } + private String rewardPath(String id) { + return DateVoteMilestones.path(DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id)), 1); + } + @Test void disablingStopsAccountingButKeepsQueuedRewardReferencesResolvable() { + event("october", ""); var m = manager(); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handles.capture()); + String stable = handles.getValue().getPath(); + config.set("DateVoteMilestones.october.Enabled", false); clearInvocations(plugin); + m.reload(); verify(plugin).addDirectlyDefinedRewards(handles.capture()); + assertEquals(stable, handles.getValue().getPath()); + assertEquals("Thanks", handles.getValue().getFileData().getString(stable + ".Messages.Player")); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void caseDistinctEventsHaveDistinctRewardRegistryHandlesAndActualConfigurations() { + event("Festival", ""); event("festival", ""); + config.set("DateVoteMilestones.Festival.Milestones.1.Rewards.Messages.Player", "First"); + config.set("DateVoteMilestones.festival.Milestones.1.Rewards.Messages.Player", "Second"); + var m = manager(); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, times(2)).addDirectlyDefinedRewards(handles.capture()); + var registry = new com.bencodez.advancedcore.api.rewards.RewardRegistry(plugin); + handles.getAllValues().forEach(registry::addDirectlyDefined); + assertEquals(2, registry.getDirectlyDefinedRewards().size()); + var first = registry.getDirectlyDefined(rewardPath("Festival")); + var second = registry.getDirectlyDefined(rewardPath("festival")); + assertNotSame(first, second); + assertEquals("First", first.getFileData().getString(first.getPath() + ".Messages.Player")); + assertEquals("Second", second.getFileData().getString(second.getPath() + ".Messages.Player")); + UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), eq(first.getPath()), any()); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), eq(second.getPath()), any()); + } + @Test void fakeLegacyTargetedAndNonOwnerDeliveriesNeverCreateProgress() { + event("october", "owner"); var m = manager(); + m.accepted(user, "a", UUID.randomUUID(), time, false, true, true, false, false); + m.accepted(user, "a", UUID.randomUUID(), time, true, true, false, false, false); + m.accepted(user, "a", UUID.randomUUID(), time, true, true, true, true, false); + when(plugin.getBungeeSettings().getServer()).thenReturn("other"); + m.accepted(user, "a", UUID.randomUUID(), time, true, true, true, false, false); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + @Test void configuredNetworkRejectsLocalVoteWithoutCanonicalNetworkMetadata() { + event("october", "owner"); var m = manager(); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void overlappingWindowsUseSameOriginalOccurrenceIndependently() { + event("october", "owner"); event("festival", "owner"); var m = manager(); UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, true, true, false, false); + verify(plugin.getRewardHandler(), times(2)).giveReward(eq(user), any(), anyString(), any()); + } + @Test void failedRewardAdmissionStaysReservedAndNeverAutomaticallyRepeats() throws Exception { + event("october", ""); var m = manager(); + var handler = plugin.getRewardHandler(); + doThrow(new IllegalStateException("fixture submission failure")).when(handler).giveReward(any(), any(), anyString(), any()); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + m = manager(); m.accepted(user, "a", UUID.randomUUID(), time + 1, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(any(), any(), anyString(), any()); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(2, progress.votes()); assertEquals(java.util.Set.of(1), progress.reservedAwards()); + } + @Test void malformedDefinitionIsDisabledWithoutBreakingOtherDefinitions() { + event("broken", ""); event("valid", ""); config.set("DateVoteMilestones.broken.Timezone", "No/SuchZone"); + var m = manager(); m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), eq(rewardPath("valid")), any()); + } + @Test void milestoneMustHaveCanonicalThresholdAndActualRewardSection() { + event("a", ""); config.set("DateVoteMilestones.a.Milestones.01.Rewards.Messages.Player", "bad"); + assertThrows(IllegalArgumentException.class, () -> DateVoteMilestones.parse("a", config.getConfigurationSection("DateVoteMilestones.a"))); + } + @Test void progressIsWorkerScheduledBoundedReadOnlyAndReloadFenced() { + event("october", ""); var m = manager(); + UUID storageId = UUID.randomUUID(); + when(user.getJavaUUID()).thenReturn(storageId); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + clearInvocations(plugin.getRewardHandler()); + var playerEntity = mock(org.bukkit.entity.Player.class); + when(playerEntity.getUniqueId()).thenReturn(player); + when(playerEntity.getName()).thenReturn("Player"); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(player, "Player")).thenReturn(user); + when(playerEntity.isOnline()).thenReturn(true); + when(playerEntity.hasPermission(anyString())).thenReturn(true); + when(plugin.isEnabled()).thenReturn(true); + java.util.Queue worker = new java.util.ArrayDeque<>(), entity = new java.util.ArrayDeque<>(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doAnswer(invocation -> { worker.add(invocation.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try (var scheduler = mockStatic(com.bencodez.votingplugin.util.BukkitCompletionScheduler.class)) { + scheduler.when(() -> com.bencodez.votingplugin.util.BukkitCompletionScheduler.run(eq(plugin), eq(playerEntity), + any(Runnable.class), any(Runnable.class), any(Runnable.class))).thenAnswer(invocation -> { + entity.add(invocation.getArgument(2)); return null; + }); + m.progress(playerEntity); entity.remove().run(); + for (int i = 0; i < 100; i++) { m.progress(playerEntity); entity.remove().run(); } + assertEquals(1, worker.size()); + verify(playerEntity, never()).sendMessage(startsWith("october (")); + worker.remove().run(); assertEquals(1, entity.size()); + entity.remove().run(); verify(playerEntity).sendMessage(contains(": 1 votes;")); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertEquals(1, countPlayerRecords(root.resolve("date-vote-milestones"))); + clearInvocations(playerEntity); + m.progress(playerEntity); entity.remove().run(); worker.remove().run(); + m.reload(); entity.remove().run(); + verify(playerEntity, never()).sendMessage(startsWith("october (")); + } + } + private long countPlayerRecords(Path directory) { + try (var files = Files.list(directory)) { + return files.filter(p -> !p.getFileName().toString().equals("definitions.properties")).count(); + } catch (java.io.IOException failure) { throw new AssertionError(failure); } + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java new file mode 100644 index 0000000000..0930d6468f --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java @@ -0,0 +1,32 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; + +class ProxyVoteRealFlagTest { + @Test void nativeBackendAdapterPreservesCanonicalTestFlagAndLegacyDefaults() throws Exception { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin); + doReturn("Player").when(user).getPlayerName(); + UUID id = UUID.randomUUID(); + user.bungeeVotePluginMessaging("service", 123, null, false, false, false, 1, + false, true, true, id, false, false); + user.bungeeVotePluginMessaging("service", 123, null, false, false, false, 1, + false, true, true, id, false); + var event = ArgumentCaptor.forClass(org.bukkit.event.Event.class); + verify(plugin.getServer().getPluginManager(), times(2)).callEvent(event.capture()); + var fake = (PlayerVoteEvent) event.getAllValues().get(0); + var legacy = (PlayerVoteEvent) event.getAllValues().get(1); + assertFalse(fake.isRealVote()); assertTrue(legacy.isRealVote()); + assertEquals(id, fake.getProxyVoteId()); assertEquals(123, fake.getTime()); + assertTrue(fake.isBungee()); assertFalse(fake.isTargetedProxyVote()); + } +} diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md new file mode 100644 index 0000000000..0c11fcae08 --- /dev/null +++ b/docs/date-vote-milestones.md @@ -0,0 +1,57 @@ +# DateVoteMilestones + +Owners define named voting events in `SpecialRewards.yml`. There are no built-in holidays, recurring rules, additional vote totals, or alternate reward engines. Existing lifetime/monthly totals and normal rewards continue unchanged. The default empty section enables nothing. + +Complete opt-in example: + +```yaml +DateVoteMilestones: + october_2026: + Enabled: true + DisplayName: 'October voting event' + Start: '2026-10-01T00:00:00' + End: '2026-11-01T00:00:00' + Timezone: 'UTC' + # Empty on a standalone server. On a proxy network, set exactly ONE backend + # BungeeSettings.yml Server name here, identically on every backend. + AccountingServer: '' + # Configured VoteSites.yml keys, NOT Votifier service names. Empty means all. + VoteSites: [] + Milestones: + '10': + Rewards: + Messages: + Player: '&aYou reached 10 votes in the October voting event!' + '25': + Rewards: + Messages: + Player: '&aYou reached 25 votes in the October voting event!' + Commands: + - 'give %player% diamond 1' +``` + +Reload after configuration. `/vote dateevents` displays the player's event counts, thresholds, active/upcoming/ended status and submitted or pending-review awards. Permission: `VotingPlugin.Commands.Vote.DateEvents` or existing `VotingPlugin.Player`. Progress inspection runs on a storage worker and responds on the player's owner scheduler. It never submits rewards. No new PlaceholderAPI placeholder is added; the progress command is the supported display. + +## Membership and ownership + +Start is inclusive; end is exclusive. Values are local ISO date-times interpreted with the explicit IANA timezone, such as `UTC` or `America/New_York`. Nonexistent or ambiguous daylight-saving boundaries are rejected; choose an unambiguous boundary. Invalid events are disabled with an explanation without disabling valid sibling events. Stable event IDs permit 1–64 letters, digits, `_` and `-`. Up to 64 configured definitions and 64 positive thresholds per event are supported; thresholds are bounded to 4096. + +Membership uses the accepted pipeline's authoritative epoch-millisecond occurrence time and occurrence UUID. Standalone ingress assigns its timestamp during accepted processing; an external site's untrusted timestamp is not used. Current identified proxy deliveries retain the original proxy occurrence time and UUID, including delayed offline delivery. A vote accepted during a window can count even when delivered after its end. Votes before start or at/after end do not count. Fake/test votes are always excluded. A matching occurrence counts once per player/event; overlapping events each count it once. Enabled-site validation remains part of the normal accepted-vote pipeline. An optional filter selects configured site keys. + +For a proxy network, **one backend owns each event ledger**. Set `AccountingServer` to that backend's exact configured server name. Use identified **all-server** delivery so that accounting backend receives every occurrence. Other backends do not increment the event, and their progress command directs players to the owner. Targeted delivery, legacy proxy deliveries missing original IDs/times, and local backend ingress on a proxy-configured server are intentionally excluded. They cannot reliably provide both complete event progress and network deduplication. This does not change their existing normal vote processing. A proxy event without an accounting owner is disabled. Moving the owner requires preserving the owner-local ledger and a new event ID; this feature does not migrate or replicate ledgers automatically. + +## Persistence, configuration changes and rewards + +Progress is stored under the accounting backend's plugin data directory, `date-vote-milestones/`, without changing existing SQL schemas or user fields. Back up that directory with the normal plugin data. Each record retains up to 4096 original occurrence UUIDs and reserved/submitted milestone state. The store has a 100,000 player/event-record capacity and a 1024 historical-definition capacity; reaching a limit rejects additional accounting rather than deleting existing state. Retention is explicit: finished events and their files are not automatically removed or recycled. Do not delete ledgers and reuse their IDs, which would re-enable grants. + +After the first qualifying vote, the event ID seals its start, end, timezone, thresholds, site filters and accounting owner. Changing those fields requires a **new ID**; unsafe edits are rejected while existing progress is preserved. Display names and enabled state can change without resetting counts. Changing reward contents affects only future submissions, never repeats a submitted/reserved threshold. Offline start/end needs no scheduled reset: each original occurrence is tested against its configured window. + +Before passing milestone rewards to AdvancedCore's existing handler, the vote count and award reservation are forced and atomically published together. After the handler returns, the award is recorded as `SUBMITTED`. Submission uses existing offline reward handling and proxy routing, with `DateVoteEvent` and `DateVoteThreshold` reward placeholders. It never calls the ordinary vote pipeline a second time. + +Arbitrary commands, inventory changes and ledger publication are not one transaction. A crash or an uncertain reward admission can leave an award `RESERVED`; it is shown as **pending review** and is deliberately not automatically resubmitted, avoiding duplicate external payouts. `SUBMITTED` means accepted by the existing handler, not proof that every external command succeeded. An owner must inspect ambiguous cases and decide on manual recovery. Normal restarts, duplicates, reloads and display-name edits do not reissue rewards. Corrupt, oversized or unsafe state fails closed rather than resetting progress. No universal exactly-once payout guarantee is claimed. + +Persisted player files use a SHA-256 event-ID filename so case-distinct event IDs remain distinct on Windows and other case-insensitive filesystems. Accounting contracts use length-prefixed fields; incomplete reached-award records fail closed for administrator review. Progress resolves the same stored player identity as vote accounting on a worker, including offline UUID normalization. Proxy test flags are preserved into the existing vote event; legacy Java overloads retain their real-vote default. Standalone occurrence membership uses the actual current instant, including daylight-saving rollback. + +Disabling an event stops new accounting but retains its existing reward handles for offline/delayed awards. Keep disabled event definitions and their reward sections until previously queued rewards have been delivered; deleting definitions is not a supported cancellation mechanism for submitted rewards. Internal reward references use the case-sensitive event-ID hash, preserving distinct rewards even though AdvancedCore's ordinary registry lookup is case-insensitive. Owner-facing reward configuration and edits remain under the named event's `Milestones..Rewards` section; internal aliases are never saved into `SpecialRewards.yml`. An existing empty definitions file is treated as corruption, not a fresh install. + +If definition metadata is missing or omits an event with surviving player history, accounting and progress fail closed for that event until the historical seal is repaired. A new player cannot reseal the old ID under edited accounting fields. Other intact seals and genuinely new event IDs remain usable. From 3c29bd867480c0f533dc8b9808b277df793d070e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:20:02 -0600 Subject: [PATCH 02/22] Respect reward processing and preserve queued date milestone handles --- .../core/datemilestones/DateVoteEvent.java | 6 +- .../datemilestones/DateVoteLedger.java | 25 ++++--- .../datemilestones/DateVoteMilestones.java | 65 ++++++++++++------- .../datemilestones/DateVoteLedgerTest.java | 17 +++++ .../DateVoteMilestonesTest.java | 49 ++++++++++++++ docs/date-vote-milestones.md | 4 ++ 6 files changed, 132 insertions(+), 34 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java index e269825db4..0555093fc5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/datemilestones/DateVoteEvent.java @@ -45,7 +45,11 @@ public String fingerprint() { } catch (java.io.IOException impossible) { throw new IllegalStateException(impossible); } } /** Portable case-sensitive identity, including on case-insensitive filesystems. */ - public String fileId() { return hash(id.getBytes(StandardCharsets.UTF_8)); } + public String fileId() { return fileId(id); } + public static String fileId(String id) { + if (id == null || !id.matches("[A-Za-z0-9_-]{1,64}")) throw new IllegalArgumentException("Invalid event ID"); + return hash(id.getBytes(StandardCharsets.UTF_8)); + } private static String hash(byte[] bytes) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java index 6a62996da1..f59f749d58 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java @@ -17,12 +17,15 @@ /** Single local accounting owner. All entry points must run on a persistence worker. */ public final class DateVoteLedger { - public record Progress(int votes, Set reservedAwards, Set submittedAwards) { } + public record Progress(int votes, Set reservedAwards, Set submittedAwards, Set deferredAwards) { } private final Path directory; private int recordCount = -1; public DateVoteLedger(Path directory) { this.directory = directory.toAbsolutePath().normalize(); } public synchronized List record(DateVoteEvent event, UUID player, UUID occurrence) throws IOException { + return record(event, player, occurrence, true); + } + public synchronized List record(DateVoteEvent event, UUID player, UUID occurrence, boolean processRewards) throws IOException { seal(event); Path file = playerFile(event, player); Properties state = read(file); @@ -30,20 +33,23 @@ public synchronized List record(DateVoteEvent event, UUID player, UUID if (fingerprint != null && !fingerprint.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new event ID"); validateFingerprint(event, state); Set seen = occurrences(state); - if (seen.contains(occurrence)) return List.of(); - if (seen.size() >= 4096) throw new IOException("Event occurrence capacity reached (4096); progress retained"); + boolean changed = !seen.contains(occurrence); + if (changed && seen.size() >= 4096) throw new IOException("Event occurrence capacity reached (4096); progress retained"); seen.add(occurrence); state.setProperty("fingerprint", event.fingerprint()); state.setProperty("seen", String.join(",", seen.stream().map(UUID::toString).sorted().toList())); List awards = new ArrayList<>(); for (int threshold : event.thresholds()) { String key = "award." + threshold; - if (seen.size() >= threshold && !state.containsKey(key)) { - state.setProperty(key, "RESERVED"); awards.add(threshold); + if (seen.size() >= threshold && (!state.containsKey(key) + || processRewards && "DEFERRED".equals(state.getProperty(key)))) { + state.setProperty(key, processRewards ? "RESERVED" : "DEFERRED"); + if (processRewards) awards.add(threshold); + changed = true; } } // Reserve the whole count/award decision before invoking arbitrary external rewards. - write(file, state); + if (changed) write(file, state); return List.copyOf(awards); } public synchronized void submitted(DateVoteEvent event, UUID player, int threshold) throws IOException { @@ -60,7 +66,7 @@ public synchronized Progress progress(DateVoteEvent event, UUID player) throws I if (sealed != null && !sealed.equals(event.fingerprint())) throw new IOException("Event accounting changed; use a new ID"); Properties state = read(playerFile(event, player)); validateFingerprint(event, state); - Set reserved = new HashSet<>(), submitted = new HashSet<>(); + Set reserved = new HashSet<>(), submitted = new HashSet<>(), deferred = new HashSet<>(); for (String key : state.stringPropertyNames()) { if (key.startsWith("award.")) { int threshold; @@ -68,10 +74,11 @@ public synchronized Progress progress(DateVoteEvent event, UUID player) throws I catch (NumberFormatException invalid) { throw new IOException("Invalid award state", invalid); } if ("RESERVED".equals(state.getProperty(key))) reserved.add(threshold); else if ("SUBMITTED".equals(state.getProperty(key))) submitted.add(threshold); + else if ("DEFERRED".equals(state.getProperty(key))) deferred.add(threshold); else throw new IOException("Unknown award state"); } } - return new Progress(occurrences(state).size(), Set.copyOf(reserved), Set.copyOf(submitted)); + return new Progress(occurrences(state).size(), Set.copyOf(reserved), Set.copyOf(submitted), Set.copyOf(deferred)); } private void validateFingerprint(DateVoteEvent event, Properties state) throws IOException { if (state.isEmpty()) return; @@ -145,7 +152,7 @@ private Properties read(Path file) throws IOException { if (!key.matches("award\\.[1-9][0-9]{0,3}")) throw new IOException("Unknown milestone state field"); int threshold = Integer.parseInt(key.substring(6)); if (threshold > 4096 || threshold > seen.size() - || !(state.getProperty(key).equals("RESERVED") || state.getProperty(key).equals("SUBMITTED"))) + || !(state.getProperty(key).equals("RESERVED") || state.getProperty(key).equals("SUBMITTED") || state.getProperty(key).equals("DEFERRED"))) throw new IOException("Invalid award state"); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java index bd9f80aca4..0f90b9a37d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -39,42 +39,58 @@ public void reload() { definitions = List.of(); plugin.getLogger().warning("DateVoteMilestones configuration snapshot invalid"); return; } } + int considered = 0; if (root != null) for (String id : root.getKeys(false)) { - if (loaded.size() >= 64) { plugin.getLogger().warning("DateVoteMilestones supports at most 64 configured events"); break; } + if (++considered > 64) { plugin.getLogger().warning("DateVoteMilestones supports at most 64 configured events"); break; } try { ConfigurationSection section = root.getConfigurationSection(id); if (section == null) throw new IllegalArgumentException("Expected an event section"); + registerRewardHandles(id, section, rewards); DateVoteEvent event = parse(id, section); if (plugin.getBungeeSettings().isUseBungeecoord() && event.accountingServer().isBlank()) throw new IllegalArgumentException("Proxy events require one explicit AccountingServer and all-server identified delivery"); loaded.add(new Definition(event, rewards)); - // Disabled events retain reward handles for previously queued awards. - for (int threshold : event.thresholds()) { - String source = configPath(event, threshold), alias = path(event, threshold); - rewards.createSection(alias, rewards.getConfigurationSection(source).getValues(true)); - plugin.addDirectlyDefinedRewards(new DirectlyDefinedReward(alias) { - private String sourcePath(String requested) { - if (!requested.equals(alias) && !requested.startsWith(alias + ".")) - throw new IllegalArgumentException("Unexpected date milestone reward path"); - return source + requested.substring(alias.length()); - } - @Override public ConfigurationSection getFileData() { - YamlConfiguration data = new YamlConfiguration(); - var section = plugin.getSpecialRewardsConfig().getData().getConfigurationSection(source); - if (section != null) data.createSection(alias, section.getValues(true)); - return data; - } - @Override public void createSection(String key) { plugin.getSpecialRewardsConfig().createSection(sourcePath(key)); } - @Override public void setData(String key, Object value) { plugin.getSpecialRewardsConfig().setValue(sourcePath(key), value); } - @Override public void save() { plugin.getSpecialRewardsConfig().saveData(); } - }); - } } catch (Exception invalid) { plugin.getLogger().warning("DateVoteMilestones " + id + " disabled: " + invalid.getMessage()); } } definitions = List.copyOf(loaded); } + /** Queued rewards must resolve even when an accounting-only setting is invalid. */ + private void registerRewardHandles(String id, ConfigurationSection section, YamlConfiguration rewards) { + String fileId = DateVoteEvent.fileId(id); + var milestones = section.getConfigurationSection("Milestones"); + if (milestones == null) return; + int count = 0; + for (String key : milestones.getKeys(false)) { + if (++count > 64) break; + int threshold; + try { threshold = Integer.parseInt(key); } + catch (NumberFormatException invalid) { continue; } + if (threshold <= 0 || threshold > 4096 || !key.equals(Integer.toString(threshold))) continue; + String source = "DateVoteMilestones." + id + ".Milestones." + threshold + ".Rewards"; + var configured = rewards.getConfigurationSection(source); + if (configured == null) continue; + String alias = "DateVoteMilestonesRuntime." + fileId + ".Milestones." + threshold + ".Rewards"; + rewards.createSection(alias, configured.getValues(true)); + plugin.addDirectlyDefinedRewards(new DirectlyDefinedReward(alias) { + private String sourcePath(String requested) { + if (!requested.equals(alias) && !requested.startsWith(alias + ".")) + throw new IllegalArgumentException("Unexpected date milestone reward path"); + return source + requested.substring(alias.length()); + } + @Override public ConfigurationSection getFileData() { + YamlConfiguration data = new YamlConfiguration(); + var current = plugin.getSpecialRewardsConfig().getData().getConfigurationSection(source); + if (current != null) data.createSection(alias, current.getValues(true)); + return data; + } + @Override public void createSection(String path) { plugin.getSpecialRewardsConfig().createSection(sourcePath(path)); } + @Override public void setData(String path, Object value) { plugin.getSpecialRewardsConfig().setValue(sourcePath(path), value); } + @Override public void save() { plugin.getSpecialRewardsConfig().saveData(); } + }); + } + } public static DateVoteEvent parse(String id, ConfigurationSection section) { String zone = section.getString("Timezone"); String start = section.getString("Start"), end = section.getString("End"); @@ -107,7 +123,8 @@ public void accepted(VotingPluginUser user, String site, UUID occurrence, long o DateVoteEvent event = definition.event(); if (!event.matches(occurredAt, site, real, proxy, plugin.getBungeeSettings().getServer())) continue; try { - for (int threshold : ledger().record(event, user.getJavaUUID(), occurrence)) { + for (int threshold : ledger().record(event, user.getJavaUUID(), occurrence, plugin.getOptions().isProcessRewards())) { + if (!plugin.getOptions().isProcessRewards()) continue; var placeholders = new HashMap(); placeholders.put("DateVoteEvent", event.displayName()); placeholders.put("DateVoteThreshold", Integer.toString(threshold)); @@ -153,7 +170,7 @@ public void progress(Player player) { long now = System.currentTimeMillis(); String state = now < event.start() ? "upcoming" : now >= event.end() ? "ended" : "active"; messages.add(event.displayName() + " (" + state + "): " + progress.votes() + " votes; milestones " + event.thresholds() - + "; submitted " + progress.submittedAwards() + "; pending review " + progress.reservedAwards()); + + "; submitted " + progress.submittedAwards() + "; deferred " + progress.deferredAwards() + "; pending review " + progress.reservedAwards()); } catch (IOException failure) { messages.add(event.displayName() + ": progress unavailable; ask an administrator."); } } BukkitCompletionScheduler.run(plugin, player, () -> { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java index a082740696..395002c66a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java @@ -16,6 +16,23 @@ class DateVoteLedgerTest { private DateVoteEvent event(String id, String name, long end) { return new DateVoteEvent(id, name, true, 100, end, "UTC", List.of(1, 2), Set.of(), ""); } + @Test void deferredThresholdPromotesOnceAcrossConcurrentReplayAndNeverDemotesAnUncertainReservation() throws Exception { + var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); UUID occurrence = UUID.randomUUID(); + assertEquals(List.of(), ledger.record(e, player, occurrence, false)); + ledger = new DateVoteLedger(root); assertEquals(Set.of(1), ledger.progress(e, player).deferredAwards()); + final DateVoteLedger resumed = ledger; + try (var pool = Executors.newFixedThreadPool(4)) { + var jobs = java.util.stream.IntStream.range(0, 20).mapToObj(i -> (java.util.concurrent.Callable>) + () -> resumed.record(e, player, occurrence, true)).toList(); + int admitted = 0; + for (var result : pool.invokeAll(jobs)) admitted += result.get().size(); + assertEquals(1, admitted); + } + assertEquals(List.of(), ledger.record(e, player, occurrence, false)); + assertEquals(Set.of(1), ledger.progress(e, player).reservedAwards()); + assertTrue(ledger.progress(e, player).deferredAwards().isEmpty()); assertEquals(1, ledger.progress(e, player).votes()); + assertEquals(List.of(), new DateVoteLedger(root).record(e, player, occurrence, true)); + } @Test void duplicateAndRestartNeverResubmitReservedOrSubmittedAwards() throws Exception { var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); UUID id = UUID.randomUUID(); assertEquals(List.of(1), ledger.record(e, player, id)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java index 26680196f2..99a57383df 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -29,8 +29,57 @@ private DateVoteMilestones manager() { when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); when(user.getJavaUUID()).thenReturn(player); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); var m = new DateVoteMilestones(plugin); m.reload(); return m; } + @Test void disabledRewardProcessingCountsWithoutSubmittingOrLosingThePendingThreshold() throws Exception { + event("october", ""); var m = manager(); + when(plugin.getOptions().isProcessRewards()).thenReturn(false); + UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(1, progress.votes()); assertEquals(java.util.Set.of(1), progress.deferredAwards()); + assertTrue(progress.reservedAwards().isEmpty()); assertTrue(progress.submittedAwards().isEmpty()); + } + @Test void confirmedDeferredAwardsResumeOnceAfterRestartAndRewardProcessingIsEnabled() throws Exception { + event("october", ""); var m = manager(); + when(plugin.getOptions().isProcessRewards()).thenReturn(false); + UUID occurrence = UUID.randomUUID(); + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m = manager(); // Fresh manager and on-disk ledger; manager enables processing. + m.accepted(user, "a", occurrence, time, true, false, false, false, false); + m.accepted(user, "a", UUID.randomUUID(), time + 1, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(rewardPath("october")), any()); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(2, progress.votes()); assertEquals(java.util.Set.of(1), progress.submittedAwards()); + assertTrue(progress.deferredAwards().isEmpty()); + } + @Test void invalidTimezoneStopsAccountingButPreservesQueuedRewardResolutionOnReload() { + event("october", ""); var m = manager(); String alias = rewardPath("october"); + config.set("DateVoteMilestones.october.Timezone", "No/SuchZone"); clearInvocations(plugin); + m.reload(); + var handle = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handle.capture()); + assertEquals(alias, handle.getValue().getPath()); + assertEquals("Thanks", handle.getValue().getFileData().getString(alias + ".Messages.Player")); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + @Test void accountingInvalidAtStartupStillRegistersExistingCanonicalRewardSections() { + event("october", ""); String alias = rewardPath("october"); + config.set("DateVoteMilestones.october.End", "not-a-date"); var m = manager(); + var handle = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin).addDirectlyDefinedRewards(handle.capture()); + assertEquals(alias, handle.getValue().getPath()); + assertEquals("Thanks", handle.getValue().getFileData().getString(alias + ".Messages.Player")); + m.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } @Test void configuredRewardsUseExistingHandlerOnceAcrossReplayRestartAndRename() { event("october", ""); var m = manager(); UUID occurrence = UUID.randomUUID(); m.accepted(user, "a", occurrence, time, true, false, false, false, false); diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 0c11fcae08..b77b752f89 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -55,3 +55,7 @@ Persisted player files use a SHA-256 event-ID filename so case-distinct event ID Disabling an event stops new accounting but retains its existing reward handles for offline/delayed awards. Keep disabled event definitions and their reward sections until previously queued rewards have been delivered; deleting definitions is not a supported cancellation mechanism for submitted rewards. Internal reward references use the case-sensitive event-ID hash, preserving distinct rewards even though AdvancedCore's ordinary registry lookup is case-insensitive. Owner-facing reward configuration and edits remain under the named event's `Milestones..Rewards` section; internal aliases are never saved into `SpecialRewards.yml`. An existing empty definitions file is treated as corruption, not a fresh install. If definition metadata is missing or omits an event with surviving player history, accounting and progress fail closed for that event until the historical seal is repaired. A new player cannot reseal the old ID under edited accounting fields. Other intact seals and genuinely new event IDs remain usable. + +`ProcessRewards: false` still records qualifying date-event votes but stores reached thresholds as `DEFERRED`, without invoking the reward handler. Once processing is enabled, the next qualifying accepted vote notification for that player/event reserves and submits those known unattempted thresholds once; an identified replay reaching this observer does not increment the counter. Reload/restart preserves deferred state. There is no background sweep: an ended event with no further qualifying delivery keeps its deferred thresholds. A setting toggle during an admitted reservation can leave `RESERVED` for conservative owner reconciliation; it never turns an ambiguous submission into an automatic retry. + +Runtime reward aliases register from canonical milestone reward sections independently of date/timezone/accounting validation. An accounting-only typo disables new progress while existing queued/offline awards can still resolve their unchanged reward section after reload or restart. Keep the event ID and reward sections until queued awards finish; deleting those sections is not a supported cancellation mechanism. From 1f787df070ad29b6724a84188ce7c0bcebf46d7f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:36:10 -0600 Subject: [PATCH 03/22] Initialize date vote definitions before proxy ingress --- .../votingplugin/VotingPluginMain.java | 12 ++++-- .../DateVoteIngressOrderingTest.java | 38 +++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 62ae6a865a..4d2c0767e5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -684,6 +684,12 @@ private void metrics() { new VotingPluginMetrics().load(plugin); } + /** Publish configuration and reward aliases before any vote producer can deliver. */ + void initializeDateVoteIngress(Runnable openProxy) { + getDateVoteMilestones().reload(); + if (getBungeeSettings().isUseBungeecoord()) openProxy.run(); + } + @Override public void onPostLoad() { ensureCommunicationSecret(); @@ -716,9 +722,7 @@ public void onPostLoad() { loadVoteTimer(); getVotingPluginUserManager().startSharedPointTransferRecovery(); - if (bungeeSettings.isUseBungeecoord()) { - loadBungeeHandler(); - } + initializeDateVoteIngress(this::loadBungeeHandler); if (!bungeeSettings.isUseBungeecoord() || !bungeeSettings.isGloblalDataEnabled()) { this.timeQueueHandler = new TimeQueueHandler(this); @@ -2134,6 +2138,8 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl, // Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique. getVotingPluginUserManager().startSharedPointTransferRecovery(); + getOptions().setServer(bungeeSettings.getServer()); + getDateVoteMilestones().reload(); reloadBackendProxyRuntime(updateActiveBackendRuntime, userStorage); checkYMLError(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java new file mode 100644 index 0000000000..1446706af9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -0,0 +1,38 @@ +package com.bencodez.votingplugin; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import java.nio.file.Path; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.bukkit.configuration.file.YamlConfiguration; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones; +import com.bencodez.votingplugin.user.VotingPluginUser; +class DateVoteIngressOrderingTest { + @TempDir Path root; + @Test void firstTransportDeliveryFindsDefinitionsAndRewardHandlesAlreadyPublished() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + YamlConfiguration config = new YamlConfiguration(); + String prefix = "DateVoteMilestones.startup."; + config.set(prefix + "Enabled", true); config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "Start", "2020-01-01T00:00:00"); config.set(prefix + "End", "2099-01-01T00:00:00"); + config.set(prefix + "AccountingServer", "backend"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + when(plugin.getBungeeSettings().getServer()).thenReturn("backend"); + DateVoteMilestones events = new DateVoteMilestones(plugin); + when(plugin.getDateVoteMilestones()).thenReturn(events); + VotingPluginUser user = mock(VotingPluginUser.class); when(user.getJavaUUID()).thenReturn(UUID.randomUUID()); + Runnable ingress = () -> { + verify(plugin).addDirectlyDefinedRewards(any()); + events.accepted(user, "a", UUID.randomUUID(), System.currentTimeMillis(), true, true, true, false, false); + }; + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + plugin.initializeDateVoteIngress(ingress); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + assertTrue(java.nio.file.Files.exists(root.resolve("date-vote-milestones"))); + } +} From 073634b899ca799d83eaa47b0bbdff0ce23e10a8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:01:42 -0600 Subject: [PATCH 04/22] Reserve date milestone rewards individually before submission --- .../datemilestones/DateVoteLedger.java | 19 ++++-- .../datemilestones/DateVoteMilestones.java | 3 +- .../datemilestones/DateVoteLedgerTest.java | 19 +++++- .../DateVoteMilestonesTest.java | 61 +++++++++++++++++++ docs/date-vote-milestones.md | 2 +- 5 files changed, 94 insertions(+), 10 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java index f59f749d58..6df240bcec 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedger.java @@ -41,17 +41,26 @@ public synchronized List record(DateVoteEvent event, UUID player, UUID List awards = new ArrayList<>(); for (int threshold : event.thresholds()) { String key = "award." + threshold; - if (seen.size() >= threshold && (!state.containsKey(key) - || processRewards && "DEFERRED".equals(state.getProperty(key)))) { - state.setProperty(key, processRewards ? "RESERVED" : "DEFERRED"); - if (processRewards) awards.add(threshold); + if (seen.size() >= threshold && !state.containsKey(key)) { + state.setProperty(key, "DEFERRED"); changed = true; } + if (processRewards && "DEFERRED".equals(state.getProperty(key))) awards.add(threshold); } - // Reserve the whole count/award decision before invoking arbitrary external rewards. + // Persist counts and known unattempted awards; each submission claims only its own threshold. if (changed) write(file, state); return List.copyOf(awards); } + /** Atomically admit one known unattempted threshold immediately before its external submission. */ + public synchronized boolean reserve(DateVoteEvent event, UUID player, int threshold) throws IOException { + Path file = playerFile(event, player); + Properties state = read(file); + validateFingerprint(event, state); + if (!event.thresholds().contains(threshold) || !"DEFERRED".equals(state.getProperty("award." + threshold))) return false; + state.setProperty("award." + threshold, "RESERVED"); + write(file, state); + return true; + } public synchronized void submitted(DateVoteEvent event, UUID player, int threshold) throws IOException { Path file = playerFile(event, player); Properties state = read(file); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java index 0f90b9a37d..c14f27b25f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -124,10 +124,11 @@ public void accepted(VotingPluginUser user, String site, UUID occurrence, long o if (!event.matches(occurredAt, site, real, proxy, plugin.getBungeeSettings().getServer())) continue; try { for (int threshold : ledger().record(event, user.getJavaUUID(), occurrence, plugin.getOptions().isProcessRewards())) { - if (!plugin.getOptions().isProcessRewards()) continue; + if (!plugin.getOptions().isProcessRewards()) break; var placeholders = new HashMap(); placeholders.put("DateVoteEvent", event.displayName()); placeholders.put("DateVoteThreshold", Integer.toString(threshold)); + if (!ledger().reserve(event, user.getJavaUUID(), threshold)) continue; plugin.getRewardHandler().giveReward(user, definition.rewards(), path(event, threshold), new RewardOptions().setPrefix("DateVoteMilestones-" + event.id() + "-" + threshold) .setServer(forceProxyRouting).setPlaceholders(placeholders)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java index 395002c66a..ecc07c0153 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteLedgerTest.java @@ -23,7 +23,13 @@ private DateVoteEvent event(String id, String name, long end) { final DateVoteLedger resumed = ledger; try (var pool = Executors.newFixedThreadPool(4)) { var jobs = java.util.stream.IntStream.range(0, 20).mapToObj(i -> (java.util.concurrent.Callable>) - () -> resumed.record(e, player, occurrence, true)).toList(); + () -> { + var pending = resumed.record(e, player, occurrence, true); + return pending.stream().filter(threshold -> { + try { return resumed.reserve(e, player, threshold); } + catch (IOException failure) { throw new java.io.UncheckedIOException(failure); } + }).toList(); + }).toList(); int admitted = 0; for (var result : pool.invokeAll(jobs)) admitted += result.get().size(); assertEquals(1, admitted); @@ -36,6 +42,7 @@ private DateVoteEvent event(String id, String name, long end) { @Test void duplicateAndRestartNeverResubmitReservedOrSubmittedAwards() throws Exception { var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); UUID id = UUID.randomUUID(); assertEquals(List.of(1), ledger.record(e, player, id)); + assertTrue(ledger.reserve(e, player, 1)); ledger = new DateVoteLedger(root); assertEquals(List.of(), ledger.record(e, player, id)); assertEquals(Set.of(1), ledger.progress(e, player).reservedAwards()); @@ -55,7 +62,13 @@ private DateVoteEvent event(String id, String name, long end) { var ledger = new DateVoteLedger(root); var e = event("a", "A", 200); UUID occurrence = UUID.randomUUID(); try (var pool = Executors.newFixedThreadPool(4)) { var jobs = java.util.stream.IntStream.range(0, 20).mapToObj(i -> (java.util.concurrent.Callable>) - () -> ledger.record(e, player, occurrence)).toList(); + () -> { + var pending = ledger.record(e, player, occurrence); + return pending.stream().filter(threshold -> { + try { return ledger.reserve(e, player, threshold); } + catch (IOException failure) { throw new java.io.UncheckedIOException(failure); } + }).toList(); + }).toList(); int awarded = 0; for (var result : pool.invokeAll(jobs)) awarded += result.get().size(); assertEquals(1, awarded); assertEquals(1, ledger.progress(e, player).votes()); @@ -81,7 +94,7 @@ private DateVoteEvent event(String id, String name, long end) { } @Test void missingReachedAwardFailsClosedForReadsWritesAndAcknowledgements() throws Exception { var e = event("a", "A", 200); var ledger = new DateVoteLedger(root); - ledger.record(e, player, UUID.randomUUID()); ledger.submitted(e, player, 1); + ledger.record(e, player, UUID.randomUUID()); assertTrue(ledger.reserve(e, player, 1)); ledger.submitted(e, player, 1); Path file = root.resolve(e.fileId() + "-" + player + ".properties"); String broken = Files.readString(file).replace("award.1=SUBMITTED\n", ""); Files.writeString(file, broken); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java index 99a57383df..6c70a68358 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -58,6 +58,67 @@ private DateVoteMilestones manager() { assertEquals(2, progress.votes()); assertEquals(java.util.Set.of(1), progress.submittedAwards()); assertTrue(progress.deferredAwards().isEmpty()); } + private com.bencodez.votingplugin.core.datemilestones.DateVoteEvent deferredPair(DateVoteMilestones manager) { + when(plugin.getOptions().isProcessRewards()).thenReturn(false); + manager.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + manager.accepted(user, "a", UUID.randomUUID(), time + 1, true, false, false, false, false); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + return DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + } + @Test void earlierSubmissionFailureDoesNotReserveAnUnattemptedLaterThreshold() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var manager = manager(); var definition = deferredPair(manager); + var first = DateVoteMilestones.path(definition, 1); var second = DateVoteMilestones.path(definition, 2); + var handler = plugin.getRewardHandler(); + doThrow(new IllegalStateException("fixture uncertain submission")).when(handler) + .giveReward(eq(user), any(), eq(first), any()); + UUID delivery = UUID.randomUUID(); + manager.accepted(user, "a", delivery, time + 2, true, false, false, false, false); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(java.util.Set.of(1), progress.reservedAwards()); + assertEquals(java.util.Set.of(2), progress.deferredAwards()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(second), any()); + manager = manager(); // Restart and replay must retain the earlier uncertain reservation. + manager.accepted(user, "a", delivery, time + 2, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(first), any()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(second), any()); + progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(3, progress.votes()); assertEquals(java.util.Set.of(2), progress.submittedAwards()); + } + @Test void acknowledgementFailureDoesNotReserveAnUnattemptedLaterThreshold() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var manager = manager(); var definition = deferredPair(manager); + try (var publication = mockStatic(com.bencodez.votingplugin.util.DurableFiles.class)) { + publication.when(() -> com.bencodez.votingplugin.util.DurableFiles.publishStagedFile(any(Path.class), any(Path.class))) + .thenAnswer(invocation -> { + Path staged = invocation.getArgument(0); + if (Files.readString(staged).contains("award.1=SUBMITTED")) throw new java.io.IOException("fixture acknowledgement failure"); + return invocation.callRealMethod(); + }); + manager.accepted(user, "a", UUID.randomUUID(), time + 2, true, false, false, false, false); + } + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(java.util.Set.of(1), progress.reservedAwards()); assertEquals(java.util.Set.of(2), progress.deferredAwards()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + manager = manager(); manager.accepted(user, "a", UUID.randomUUID(), time + 3, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + } + @Test void turningRewardsOffBetweenSubmissionsKeepsTheLaterThresholdDeferred() throws Exception { + event("october", ""); config.set("DateVoteMilestones.october.Milestones.2.Rewards.Messages.Player", "Second"); + var manager = manager(); var definition = deferredPair(manager); + var handler = plugin.getRewardHandler(); + doAnswer(invocation -> { when(plugin.getOptions().isProcessRewards()).thenReturn(false); return null; }) + .when(handler).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 1)), any()); + manager.accepted(user, "a", UUID.randomUUID(), time + 2, true, false, false, false, false); + var progress = new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player); + assertEquals(java.util.Set.of(1), progress.submittedAwards()); assertEquals(java.util.Set.of(2), progress.deferredAwards()); + assertTrue(progress.reservedAwards().isEmpty()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + manager = manager(); manager.accepted(user, "a", UUID.randomUUID(), time + 3, true, false, false, false, false); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), eq(DateVoteMilestones.path(definition, 2)), any()); + } @Test void invalidTimezoneStopsAccountingButPreservesQueuedRewardResolutionOnReload() { event("october", ""); var m = manager(); String alias = rewardPath("october"); config.set("DateVoteMilestones.october.Timezone", "No/SuchZone"); clearInvocations(plugin); diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index b77b752f89..d09f1bb563 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -56,6 +56,6 @@ Disabling an event stops new accounting but retains its existing reward handles If definition metadata is missing or omits an event with surviving player history, accounting and progress fail closed for that event until the historical seal is repaired. A new player cannot reseal the old ID under edited accounting fields. Other intact seals and genuinely new event IDs remain usable. -`ProcessRewards: false` still records qualifying date-event votes but stores reached thresholds as `DEFERRED`, without invoking the reward handler. Once processing is enabled, the next qualifying accepted vote notification for that player/event reserves and submits those known unattempted thresholds once; an identified replay reaching this observer does not increment the counter. Reload/restart preserves deferred state. There is no background sweep: an ended event with no further qualifying delivery keeps its deferred thresholds. A setting toggle during an admitted reservation can leave `RESERVED` for conservative owner reconciliation; it never turns an ambiguous submission into an automatic retry. +`ProcessRewards: false` still records qualifying date-event votes but stores reached thresholds as `DEFERRED`, without invoking the reward handler. Once processing is enabled, the next qualifying accepted vote notification for that player/event reserves and submits those known unattempted thresholds once; an identified replay reaching this observer does not increment the counter. Reload/restart preserves deferred state. There is no background sweep: an ended event with no further qualifying delivery keeps its deferred thresholds. Each threshold is reserved separately immediately before invoking the reward handler. If an earlier submission or its acknowledgement fails, later unattempted thresholds stay `DEFERRED` for a subsequent qualifying notification, including after restart. Turning processing off before the next threshold leaves it deferred. An uncertain attempted submission remains `RESERVED` for conservative owner reconciliation and is never automatically retried. Runtime reward aliases register from canonical milestone reward sections independently of date/timezone/accounting validation. An accounting-only typo disables new progress while existing queued/offline awards can still resolve their unchanged reward section after reload or restart. Keep the event ID and reward sections until queued awards finish; deleting those sections is not a supported cancellation mechanism. From bd3c7382e8eb5be1ff5b509a18535e57f980dbe1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 00:45:01 -0600 Subject: [PATCH 05/22] Open proxy ingress after accepted vote handlers initialize --- .../votingplugin/VotingPluginMain.java | 9 +++++++-- .../DateVoteIngressOrderingTest.java | 19 ++++++++++++++++++- docs/date-vote-milestones.md | 2 ++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 4d2c0767e5..2d21719387 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -684,8 +684,12 @@ private void metrics() { new VotingPluginMetrics().load(plugin); } - /** Publish configuration and reward aliases before any vote producer can deliver. */ + /** Publish proxy ingress only after the complete accepted-vote pipeline is initialized. */ void initializeDateVoteIngress(Runnable openProxy) { + if (voteMilestonesManager == null || voteStreakHandler == null || voteParty == null || specialRewards == null + || topVoterHandler == null || voteShopManager == null || placeholders == null) { + throw new IllegalStateException("Accepted-vote handlers must initialize before proxy ingress"); + } getDateVoteMilestones().reload(); if (getBungeeSettings().isUseBungeecoord()) openProxy.run(); } @@ -722,7 +726,7 @@ public void onPostLoad() { loadVoteTimer(); getVotingPluginUserManager().startSharedPointTransferRecovery(); - initializeDateVoteIngress(this::loadBungeeHandler); + // Proxy ingress opens below, after every accepted-vote handler is initialized. if (!bungeeSettings.isUseBungeecoord() || !bungeeSettings.isGloblalDataEnabled()) { this.timeQueueHandler = new TimeQueueHandler(this); @@ -831,6 +835,7 @@ public Object getObject(OfflinePlayer player) { } VotingPluginRewardRegistrar.register(this); + initializeDateVoteIngress(this::loadBungeeHandler); // Recovered Votifier votes may now traverse the fully initialized vote, // reward, placeholder, shop, and vote-party pipeline. if (votifierVoteOverflowQueue != null) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java index 1446706af9..591bf551ef 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -10,7 +10,7 @@ import com.bencodez.votingplugin.user.VotingPluginUser; class DateVoteIngressOrderingTest { @TempDir Path root; - @Test void firstTransportDeliveryFindsDefinitionsAndRewardHandlesAlreadyPublished() { + @Test void firstTransportDeliveryFindsDefinitionsAndRewardHandlesAlreadyPublished() throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); YamlConfiguration config = new YamlConfiguration(); String prefix = "DateVoteMilestones.startup."; @@ -30,9 +30,26 @@ class DateVoteIngressOrderingTest { verify(plugin).addDirectlyDefinedRewards(any()); events.accepted(user, "a", UUID.randomUUID(), System.currentTimeMillis(), true, true, true, false, false); }; + ready(plugin); doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); plugin.initializeDateVoteIngress(ingress); verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any()); assertTrue(java.nio.file.Files.exists(root.resolve("date-vote-milestones"))); } + private static final String[] HANDLERS = {"voteMilestonesManager", "voteStreakHandler", "voteParty", "specialRewards", "topVoterHandler", "voteShopManager", "placeholders"}; + private static void ready(VotingPluginMain plugin) throws Exception { + for (String name : HANDLERS) { + var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, mock(field.getType())); + } + } + @Test void eachMissingDownstreamHandlerKeepsIngressClosed() throws Exception { + for (String name : HANDLERS) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); ready(plugin); + var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, null); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); Runnable ingress = mock(Runnable.class); + assertThrows(IllegalStateException.class, () -> plugin.initializeDateVoteIngress(ingress)); verify(ingress, never()).run(); + verify(plugin.getDateVoteMilestones(), never()).reload(); + } + } + } diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index d09f1bb563..e608e18530 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -59,3 +59,5 @@ If definition metadata is missing or omits an event with surviving player histor `ProcessRewards: false` still records qualifying date-event votes but stores reached thresholds as `DEFERRED`, without invoking the reward handler. Once processing is enabled, the next qualifying accepted vote notification for that player/event reserves and submits those known unattempted thresholds once; an identified replay reaching this observer does not increment the counter. Reload/restart preserves deferred state. There is no background sweep: an ended event with no further qualifying delivery keeps its deferred thresholds. Each threshold is reserved separately immediately before invoking the reward handler. If an earlier submission or its acknowledgement fails, later unattempted thresholds stay `DEFERRED` for a subsequent qualifying notification, including after restart. Turning processing off before the next threshold leaves it deferred. An uncertain attempted submission remains `RESERVED` for conservative owner reconciliation and is never automatically retried. Runtime reward aliases register from canonical milestone reward sections independently of date/timezone/accounting validation. An accounting-only typo disables new progress while existing queued/offline awards can still resolve their unchanged reward section after reload or restart. Keep the event ID and reward sections until queued awards finish; deleting those sections is not a supported cancellation mechanism. + +Proxy transports open only after the existing milestone, streak, vote-party, special-reward, top-voter, shop and placeholder handlers have initialized and reward registration has finished. Date definitions and reward aliases are published before that ingress opens. From ff41566f4eef8c4445cfddff0d8fa452ed0aabde Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:02:57 -0600 Subject: [PATCH 06/22] Retain canonical proxy envelopes when date accounting is incomplete --- .../votingplugin/events/PlayerVoteEvent.java | 5 ++++ .../listeners/PlayerVoteListener.java | 4 +-- .../datemilestones/DateVoteMilestones.java | 18 ++++++++--- .../votingplugin/user/VotingPluginUser.java | 1 + .../BackendProxyMessageRouterTest.java | 30 +++++++++++++++++++ .../DateVoteMilestonesTest.java | 16 ++++++++++ .../user/ProxyVoteRealFlagTest.java | 13 ++++++++ docs/date-vote-milestones.md | 2 ++ 8 files changed, 83 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 694e463bb1..a24123c1de 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -79,6 +79,11 @@ public static HandlerList getHandlerList() { @Setter private boolean wasOnline; + /** Missing date accounting is surfaced after event dispatch so durable proxy envelopes are not acknowledged. */ + @Getter + @Setter + private boolean dateMilestoneAccountingFailed; + /** Whether this is an identified queued proxy delivery. */ @Getter @Setter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 437c766a3e..e86065ae16 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -175,8 +175,8 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { } @Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) { if (!event.isAsynchronous()) return; // No persistence from a gameplay event context. - plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, voteTime, realVote(), proxyVote(), - proxyVoteId() != null && incomingTime() > 0, targetedProxyVote(), forceProxyRouting()); + if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, voteTime, realVote(), proxyVote(), + proxyVoteId() != null && incomingTime() > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true); } @Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); } @Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java index c14f27b25f..cb5259da2b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -115,15 +115,24 @@ private static String configPath(DateVoteEvent event, int threshold) { return "DateVoteMilestones." + event.id() + ".Milestones." + threshold + ".Rewards"; } /** Called only from the real asynchronous accepted-vote pipeline; never from navigation. */ - public void accepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, + public boolean accepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) { - if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return; - if (!real || occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return; + if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return true; + if (!real || occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return true; + boolean recorded = true; for (Definition definition : definitions) { DateVoteEvent event = definition.event(); if (!event.matches(occurredAt, site, real, proxy, plugin.getBungeeSettings().getServer())) continue; + List reached; try { - for (int threshold : ledger().record(event, user.getJavaUUID(), occurrence, plugin.getOptions().isProcessRewards())) { + reached = ledger().record(event, user.getJavaUUID(), occurrence, plugin.getOptions().isProcessRewards()); + } catch (IOException | RuntimeException failure) { + recorded = false; + plugin.getLogger().warning("DateVoteMilestones " + event.id() + " occurrence was not confirmed; proxy delivery must remain quarantined for accounting reconciliation: " + failure.getMessage()); + continue; + } + try { + for (int threshold : reached) { if (!plugin.getOptions().isProcessRewards()) break; var placeholders = new HashMap(); placeholders.put("DateVoteEvent", event.displayName()); @@ -139,6 +148,7 @@ public void accepted(VotingPluginUser user, String site, UUID occurrence, long o plugin.getLogger().warning("DateVoteMilestones " + event.id() + " accounting/reward submission failed; progress retained for review: " + failure.getMessage()); } } + return recorded; } public void progress(Player player) { BukkitCompletionScheduler.run(plugin, player, () -> { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index a59383901c..22181891aa 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1008,6 +1008,7 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps voteEvent.setProxyDelayValidationKnown(proxyDelayValidationKnown); voteEvent.setTargetedProxyVote(targetedProxyVote); plugin.getServer().getPluginManager().callEvent(voteEvent); + if (voteEvent.isDateMilestoneAccountingFailed()) throw new IllegalStateException("Date milestone accounting is incomplete; retain the durable proxy envelope for reconciliation"); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index dd57bed3d4..3c018e53f3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -181,6 +181,36 @@ void voteUpdateFailureAfterOfflineEffectRequestsQuarantine() { assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); } + @Test + void actualAccountingDispatchFailureQuarantinesOriginalIdAndNeverReplaysNormalProcessing() throws Exception { + UUID voteId = UUID.randomUUID(); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveWithOutcome(voteId)).thenReturn(Reservation.RESERVED, Reservation.DUPLICATE); + var dispatchPlugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(dispatchPlugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var dispatchUser = mock(VotingPluginUser.class, org.mockito.Mockito.CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(dispatchUser, dispatchPlugin); + org.mockito.Mockito.doReturn("Player").when(dispatchUser).getPlayerName(); + var dispatcher = dispatchPlugin.getServer().getPluginManager(); + org.mockito.Mockito.doAnswer(call -> { ((com.bencodez.votingplugin.events.PlayerVoteEvent) call.getArgument(0)).setDateMilestoneAccountingFailed(true); return null; }) + .when(dispatcher).callEvent(any()); + when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user); + org.mockito.Mockito.doAnswer(call -> { + dispatchUser.bungeeVotePluginMessaging(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5), call.getArgument(6), call.getArgument(7), + call.getArgument(8), call.getArgument(9), call.getArgument(10), call.getArgument(11), call.getArgument(12)); return null; + }).when(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), + anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); + var router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); + var envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1)); + AtomicReference outcome = new AtomicReference<>(); + assertThrows(IllegalStateException.class, () -> router.handleOrderedVote(envelope, outcome::set)); + assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); outcome.set(null); + router.handleOrderedVote(envelope, outcome::set); assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); + verify(cache, never()).complete(voteId); verify(dispatchPlugin.getServer().getPluginManager(), times(1)).callEvent(any()); + } + @Test void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() { UUID voteId = UUID.randomUUID(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java index 6c70a68358..3fc6cdb939 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -32,6 +32,22 @@ private DateVoteMilestones manager() { when(plugin.getOptions().isProcessRewards()).thenReturn(true); var m = new DateVoteMilestones(plugin); m.reload(); return m; } + @Test void failedOccurrencePublicationIsReportedWithoutCountingOrRepeatingAnAward() throws Exception { + event("october", "owner"); var manager = manager(); + assertTrue(manager.accepted(user, "a", UUID.randomUUID(), time, true, true, true, false, false)); + UUID retry = UUID.randomUUID(); + try (var publication = mockStatic(com.bencodez.votingplugin.util.DurableFiles.class)) { + publication.when(() -> com.bencodez.votingplugin.util.DurableFiles.publishStagedFile(any(Path.class), any(Path.class))) + .thenThrow(new java.io.IOException("fixture occurrence unavailable")); + assertFalse(manager.accepted(user, "a", retry, time + 1, true, true, true, false, false)); + } + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + assertTrue(manager.accepted(user, "a", retry, time + 1, true, true, true, false, false)); + assertTrue(manager.accepted(user, "a", retry, time + 1, true, true, true, false, false)); + assertEquals(2, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), anyString(), any()); + } @Test void disabledRewardProcessingCountsWithoutSubmittingOrLosingThePendingThreshold() throws Exception { event("october", ""); var m = manager(); when(plugin.getOptions().isProcessRewards()).thenReturn(false); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java index 0930d6468f..2ee9b1e714 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/ProxyVoteRealFlagTest.java @@ -10,6 +10,19 @@ import com.bencodez.votingplugin.events.PlayerVoteEvent; class ProxyVoteRealFlagTest { + @Test void accountingFailureFlagSurvivesBukkitDispatchAndRejectsCompletion() throws Exception { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + var user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + var field = VotingPluginUser.class.getDeclaredField("plugin"); field.setAccessible(true); field.set(user, plugin); + doReturn("Player").when(user).getPlayerName(); + var dispatcher = plugin.getServer().getPluginManager(); + doAnswer(call -> { ((PlayerVoteEvent) call.getArgument(0)).setDateMilestoneAccountingFailed(true); return null; }) + .when(dispatcher).callEvent(any()); + assertThrows(IllegalStateException.class, () -> user.bungeeVotePluginMessaging("service", 123, null, + false, false, false, 1, false, true, true, UUID.randomUUID(), false, true)); + verify(plugin.getServer().getPluginManager(), times(1)).callEvent(any()); + } @Test void nativeBackendAdapterPreservesCanonicalTestFlagAndLegacyDefaults() throws Exception { var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index e608e18530..a777f2f91d 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -61,3 +61,5 @@ If definition metadata is missing or omits an event with surviving player histor Runtime reward aliases register from canonical milestone reward sections independently of date/timezone/accounting validation. An accounting-only typo disables new progress while existing queued/offline awards can still resolve their unchanged reward section after reload or restart. Keep the event ID and reward sections until queued awards finish; deleting those sections is not a supported cancellation mechanism. Proxy transports open only after the existing milestone, streak, vote-party, special-reward, top-voter, shop and placeholder handlers have initialized and reward registration has finished. Date definitions and reward aliases are published before that ingress opens. + +If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. From 31f5f2eeebc2e050bf882d211f98feb4f2d8c78d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:21:16 -0600 Subject: [PATCH 07/22] Require canonical transport identity and preserve original date vote time --- .../listeners/PlayerVoteListener.java | 2 +- .../proxy/multiproxy/MultiProxyHandler.java | 2 +- .../PlayerVoteListenerProxyVoteIdTest.java | 22 +++++++++++++++++++ .../MultiProxyHandlerLifecycleTest.java | 8 +++---- docs/date-vote-milestones.md | 2 ++ 5 files changed, 30 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index e86065ae16..397d1e7567 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -176,7 +176,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { @Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) { if (!event.isAsynchronous()) return; // No persistence from a gameplay event context. if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, voteTime, realVote(), proxyVote(), - proxyVoteId() != null && incomingTime() > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true); + event.getProxyVoteId() != null && incomingTime() > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true); } @Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); } @Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java index f478588b63..870ba8b033 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java @@ -1276,7 +1276,7 @@ private void handleEnvelope(JsonEnvelope envelope) { if (origin.isBlank()) { triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid); } else { - triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid, + triggerVote(player, service, realVote, true, wireVote.time, VoteTotalsSnapshot.parseStorage(totals), uuid, wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java index d8b0ae063d..824989426d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java @@ -31,4 +31,26 @@ void explicitTransportIdentityWinsOverLegacySnapshot() { assertEquals(explicitId, PlayerVoteListener.resolveProxyVoteId(event)); } + @Test void dateAccountingRequiresExplicitTransportIdentityInsteadOfLegacyCorrelationFallback() throws Exception { + for (boolean explicit : new boolean[] {false, true}) { + UUID voteId = UUID.randomUUID(); + PlayerVoteEvent event = new PlayerVoteEvent(null, "Ben", "Example", true); + event.setTime(123L); event.setBungee(true); + event.setBungeeTextTotals(new VoteTotalsSnapshot(1,2,3,4,5,6,7,8,voteId)); + if (explicit) event.setProxyVoteId(voteId); + var plugin = org.mockito.Mockito.mock(com.bencodez.votingplugin.VotingPluginMain.class); + var milestones = org.mockito.Mockito.mock(com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.class); + org.mockito.Mockito.when(plugin.getDateVoteMilestones()).thenReturn(milestones); + var user = org.mockito.Mockito.mock(com.bencodez.votingplugin.user.VotingPluginUser.class); + var site = org.mockito.Mockito.mock(com.bencodez.votingplugin.votesites.VoteSite.class); + org.mockito.Mockito.when(site.getKey()).thenReturn("Example"); + Class operations = Class.forName(PlayerVoteListener.class.getName()+"$BukkitOperations"); + var constructor = operations.getDeclaredConstructor(com.bencodez.votingplugin.VotingPluginMain.class, PlayerVoteEvent.class); constructor.setAccessible(true); + var method = operations.getDeclaredMethod("dateMilestones",com.bencodez.votingplugin.user.VotingPluginUser.class,com.bencodez.votingplugin.votesites.VoteSite.class,long.class,UUID.class);method.setAccessible(true); + method.invoke(constructor.newInstance(plugin,event),user,site,123L,voteId); + org.mockito.Mockito.verify(milestones).accepted(user,"Example",voteId,123L,true,true,explicit,false,false); + assertEquals(voteId,PlayerVoteListener.resolveProxyVoteId(event)); + } + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java index 081c21bfbe..42a6aca723 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java @@ -477,7 +477,7 @@ void unsignedBridgeCopiesDeduplicateLegacyVoteIdsButNotOriginBoundOrSignedTraffi handler.acceptRedisEnvelope(reliableVote, legacy); verify(handler, org.mockito.Mockito.times(2)).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(reliableVoteId), org.mockito.ArgumentMatchers.eq("Proxy1")); @@ -567,7 +567,7 @@ void authenticatedMultiProxyVoteIsAcceptedOnlyOnce(@TempDir Path dataDirectory) verify(handler, org.mockito.Mockito.times(1)).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Replica")); @@ -668,7 +668,7 @@ void forwardsOriginAndStableWireVoteIdToDurableTrigger() throws Exception { verify(handler).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(100L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Primary")); @@ -687,7 +687,7 @@ void forwardsPrimaryDelayValidationToDurableTrigger() throws Exception { verify(handler).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(100L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Primary"), diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index a777f2f91d..723360a98d 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -63,3 +63,5 @@ Runtime reward aliases register from canonical milestone reward sections indepen Proxy transports open only after the existing milestone, streak, vote-party, special-reward, top-voter, shop and placeholder handlers have initialized and reward registration has finished. Date definitions and reward aliases are published before that ingress opens. If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. + +Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. Reliable multi-proxy delivery retains the original wire timestamp through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older origin-less routes retain their legacy behavior and remain excluded from canonical date-event accounting. From e2f5503cfec7e06befe2bb124e57f3f8e1fead31 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 01:53:25 -0600 Subject: [PATCH 08/22] Preserve normal proxy effects and explicit date accounting identity --- .../messaging/BackendProxyMessageRouter.java | 18 +- .../events/PlayerPostVoteEvent.java | 5 + .../votingplugin/events/PlayerVoteEvent.java | 2 +- .../listeners/PlayerVoteListener.java | 1 + .../votingplugin/user/VotingPluginUser.java | 14 +- ...ckendDateVoteAccountingRegressionTest.java | 331 ++++++++++++++++++ .../BackendProxyMessageRouterTest.java | 2 + docs/date-vote-milestones.md | 4 +- 8 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index f85fb68c91..bff6a34778 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -481,16 +481,26 @@ private WireVoteResult handleWireVote(JsonEnvelope msg) { user.cache(); boolean wasOnline = vote.wasOnlineKnown ? vote.wasOnline : user.isOnline(); boolean queuedDelivery = vote.queuedDeliveryKnown ? vote.queuedDelivery : vote.delayValidated; - user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals, - wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown, - vote.queuedDeliveryKnown, voteId, - VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote); + VotingPluginUser.DateMilestoneAccountingException dateAccountingFailure = null; + try { + // Keep totals correlation for replay fencing, but only the original wire ID + // identifies a canonical transport occurrence in the native event. + user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals, + wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown, + vote.queuedDeliveryKnown, vote.voteId, + VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote); + } catch (VotingPluginUser.DateMilestoneAccountingException incomplete) { + dateAccountingFailure = incomplete; + } if (plugin.getBungeeSettings().isPerServerPoints()) { user.addPoints(plugin.getConfigFile().getPointsOnVote()); } if (vote.service != null && !vote.service.isEmpty()) { plugin.getServerData().addServiceSite(vote.service); } + // Date accounting cannot suppress normal points/service observations. Keep + // the reservation incomplete so reconciliation cannot replay normal rewards. + if (dateAccountingFailure != null) throw dateAccountingFailure; return new WireVoteResult(voteId, true, false); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java index 4902564ead..37c95737db 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java @@ -77,6 +77,11 @@ public static HandlerList getHandlerList() { @Setter private UUID voteUUID; + /** Original explicit proxy transport ID; voteUUID retains the legacy correlation fallback. */ + @Getter + @Setter + private UUID proxyVoteId; + /** * Constructs a new PlayerPostVoteEvent. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index a24123c1de..65179491b4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -38,7 +38,7 @@ public static HandlerList getHandlerList() { @Setter private VoteTotalsSnapshot bungeeTextTotals; - /** Stable identity supplied by a proxy delivery, independent of totals. */ + /** Original explicit proxy transport ID; null for legacy totals-only correlation. */ @Getter @Setter private UUID proxyVoteId; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 397d1e7567..93582ca73b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -186,6 +186,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { long voteTime, UUID voteId, boolean cached) { PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(), voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId); + post.setProxyVoteId(event.getProxyVoteId()); plugin.getServer().getPluginManager().callEvent(post); } @Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 22181891aa..dbf3856330 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -983,6 +983,18 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, true); } + /** + * Normal event processing finished, but date accounting was not confirmed. + * A router must finish its remaining normal effects before quarantining the envelope. + */ + public static final class DateMilestoneAccountingException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private DateMilestoneAccountingException() { + super("Date milestone accounting is incomplete; retain the durable proxy envelope for reconciliation"); + } + } + /** Preserve the canonical wire real/test flag; existing callers retain their historical real-vote default. */ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, @@ -1008,7 +1020,7 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps voteEvent.setProxyDelayValidationKnown(proxyDelayValidationKnown); voteEvent.setTargetedProxyVote(targetedProxyVote); plugin.getServer().getPluginManager().callEvent(voteEvent); - if (voteEvent.isDateMilestoneAccountingFailed()) throw new IllegalStateException("Date milestone accounting is incomplete; retain the durable proxy envelope for reconciliation"); + if (voteEvent.isDateMilestoneAccountingFailed()) throw new DateMilestoneAccountingException(); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java new file mode 100644 index 0000000000..3db8173326 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java @@ -0,0 +1,331 @@ +package com.bencodez.votingplugin.backendproxy.messaging; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Event; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.validation.UserValidationResult; +import com.bencodez.advancedcore.api.user.validation.ValidationSource; +import com.bencodez.advancedcore.api.user.validation.ValidationStatus; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; +import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; +import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; +import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; +import com.bencodez.votingplugin.events.PlayerPostVoteEvent; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.listeners.PlayerVoteListener; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteLedger; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.votesites.VoteSite; + +/** Exercises the router, native event adapter, accepted listener and real date ledger together. */ +class BackendDateVoteAccountingRegressionTest { + private static final UUID PLAYER = UUID.fromString("e5baec32-9b2c-4fc8-9aed-0e0285e3c33d"); + private static final long OCCURRED_AT = Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); + private static final String SERVICE = "known.example"; + private static final int POINTS = 7; + + @TempDir Path root; + private final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + private final VotingPluginUser user = mock(VotingPluginUser.class); + private final VoteSite site = mock(VoteSite.class); + private final YamlConfiguration config = new YamlConfiguration(); + private final List accepted = new ArrayList<>(); + private final List posted = new ArrayList<>(); + private final AtomicInteger storedPoints = new AtomicInteger(37); + private final ProcessedVoteCache cache = spy(new ProcessedVoteCache()); + private BackendProxyMessageRouter router; + private MockedStatic bukkit; + private MockedStatic advancedCore; + + @BeforeEach + void setUp() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getStorageType()).thenReturn(null); // Exercise ordinary synchronous point storage. + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getOptions().getServer()).thenReturn("owner"); + advancedCore=mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class); + advancedCore.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); + when(plugin.getConfigFile().getPointsOnVote()).thenReturn(POINTS); + when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getUserManager().getProperName("Player")).thenReturn("Player"); + when(plugin.getUserManager().getValidationService().validate("Player", false)).thenReturn( + new UserValidationResult(ValidationStatus.VALID, "Player", ValidationSource.STORAGE, "fixture", false)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER, "Player")).thenReturn(user); + when(plugin.getVoteSiteManager().getVoteSite(SERVICE, true)).thenReturn(site); + when(site.isEnabled()).thenReturn(true); + when(site.getKey()).thenReturn("Example"); + when(site.getServiceSite()).thenReturn(SERVICE); + when(user.getPlayerName()).thenReturn("Player"); + when(user.getUUID()).thenReturn(PLAYER.toString()); + when(user.getJavaUUID()).thenReturn(PLAYER); + when(user.isOnline()).thenReturn(true); + when(user.getPoints()).thenAnswer(ignored -> storedPoints.get()); + doAnswer(call -> { storedPoints.set(call.getArgument(0)); return null; }) + .when(user).setPoints(anyInt(), eq(false)); + var field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(user, plugin); + doCallRealMethod().when(user).addPoints(anyInt()); + doCallRealMethod().when(user).addPoints(anyInt(), anyBoolean()); + doCallRealMethod().when(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); + + var dispatcher = plugin.getServer().getPluginManager(); + var listener = new PlayerVoteListener(plugin); + doAnswer(call -> { + Event event = call.getArgument(0); + if (event instanceof PlayerVoteEvent vote) { + accepted.add(vote); + listener.onplayerVote(vote); + } else if (event instanceof PlayerPostVoteEvent post) { + posted.add(post); + } + return null; + }).when(dispatcher).callEvent(any()); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(dispatcher); + router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), + mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); + definition("october"); + reloadDefinitions(); + } + + @AfterEach + void closeBukkit() { + if (bukkit != null) bukkit.close(); + if (advancedCore != null) advancedCore.close(); + } + + private void definition(String id) { + String prefix = "DateVoteMilestones." + id + "."; + config.set(prefix + "Enabled", true); + config.set(prefix + "Start", "2026-10-01T00:00:00"); + config.set(prefix + "End", "2026-11-01T00:00:00"); + config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "AccountingServer", "owner"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + } + + private void reloadDefinitions() { + var milestones = new DateVoteMilestones(plugin); + milestones.reload(); + when(plugin.getDateVoteMilestones()).thenReturn(milestones); + } + + private DateVoteLedger.Progress progress(String id) throws Exception { + var event = DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id)); + return new DateVoteLedger(root.resolve("date-vote-milestones")).progress(event, PLAYER); + } + + private JsonEnvelope vote(UUID id, boolean queued) { + var original = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "", id, false, false, 1, 1, true); + var builder = JsonEnvelope.builder(original.getSubChannel()).schema(original.getSchema()); + original.getFields().forEach(builder::put); + return VotingPluginWire.requestVoteDeliveryAcknowledgement( + builder.put(VotingPluginWire.K_QUEUED_DELIVERY, queued).build()); + } + + @Test + void liveAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { + assertAccountingFailureFinishesNormalEffects(false); + } + + @Test + void queuedAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { + assertAccountingFailureFinishesNormalEffects(true); + } + + private void assertAccountingFailureFinishesNormalEffects(boolean queued) throws Exception { + definition("sibling"); + reloadDefinitions(); + var event = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + Path corrupt = root.resolve("date-vote-milestones").resolve(event.fileId() + "-" + PLAYER + ".properties"); + Files.createDirectories(corrupt.getParent()); + Files.writeString(corrupt, "corrupt fixture record"); + // A corrupt player record needs its existing definition seal, just as an installed ledger would have. + var definitions = new java.util.Properties(); + definitions.setProperty("october", event.fingerprint()); + try (var writer = Files.newBufferedWriter(corrupt.getParent().resolve("definitions.properties"))) { + definitions.store(writer, "fixture"); + } + UUID id = UUID.randomUUID(); + JsonEnvelope envelope = vote(id, queued); + var outcomes = new ArrayList(); + assertThrows(VotingPluginUser.DateMilestoneAccountingException.class, + () -> router.handleOrderedVote(envelope, outcome -> { + // The lane may quarantine only after the remaining normal mutation and observation. + assertEquals(44, storedPoints.get()); + verify(plugin.getServerData()).addServiceSite(SERVICE); + outcomes.add(outcome); + })); + assertEquals(List.of(OrderedVoteOutcome.QUARANTINE), outcomes); + assertTrue(accepted.get(0).isDateMilestoneAccountingFailed()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals("corrupt fixture record", Files.readString(corrupt)); + assertEquals(1, progress("sibling").votes()); + assertEquals(java.util.Set.of(1), progress("sibling").submittedAwards()); + + router.handleOrderedVote(envelope, outcomes::add); + assertEquals(List.of(OrderedVoteOutcome.QUARANTINE, OrderedVoteOutcome.QUARANTINE), outcomes); + assertEquals(44, storedPoints.get()); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertFalse(cache.hasCompletedEffects(id)); + verify(cache, never()).complete(id); + assertNormalEffectsOnce(id, OCCURRED_AT); + verify(user).addPoints(POINTS); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } + + private void assertNormalEffectsOnce(UUID id, long time) { + verify(user).playerVote(site, true, true); + verify(user).addTotal(); + verify(user).addTotalDaily(); + verify(user).addTotalWeekly(); + verify(user).addPoints(); + verify(user).setTime(site, time); + verify(plugin.getVoteParty()).vote(user, true, true, true); + verify(plugin.getVoteMilestonesManager()).handleVote(eq(user), any(), eq(true), eq(id), any()); + verify(plugin.getCoolDownCheck()).vote(user, site); + verify(plugin.getVoteStreakHandler()).processVote(user, time, id); + verify(plugin).setUpdate(true); + } + + @Test + void missingWireIdKeepsLegacyTotalsCorrelationAndDedupWithoutDateAccounting() throws Exception { + UUID correlation = UUID.randomUUID(); + // Actual legacy storage layout, including the old ignored milestone-count column. + String totals = "1//2//3//4//5//0//6//7//8//" + correlation; + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).schema(VotingPluginWire.SCHEMA_VERSION) + .put(VotingPluginWire.K_PLAYER, "Player").put(VotingPluginWire.K_UUID, PLAYER) + .put(VotingPluginWire.K_SERVICE, SERVICE).put(VotingPluginWire.K_TIME, OCCURRED_AT) + .put(VotingPluginWire.K_WAS_ONLINE, true).put(VotingPluginWire.K_REAL_VOTE, true) + .put(VotingPluginWire.K_TOTALS, totals).build(); + assertFalse(envelope.getFields().containsKey(VotingPluginWire.K_VOTE_ID)); + assertLegacyCorrelation(envelope, correlation); + } + + @Test + void emptyWireIdKeepsVersionedTotalsCorrelationWithoutDateAccounting() throws Exception { + UUID correlation = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "v2//1//2//3//4//5//6//7//8//" + correlation, null, false, false, 1, 1); + assertEquals("", envelope.getFields().get(VotingPluginWire.K_VOTE_ID)); + assertLegacyCorrelation(envelope, correlation); + } + + private void assertLegacyCorrelation(JsonEnvelope envelope, UUID correlation) throws Exception { + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertNull(accepted.get(0).getProxyVoteId()); + assertNull(posted.get(0).getProxyVoteId()); + assertEquals(correlation, posted.get(0).getVoteUUID()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + assertEquals(44, storedPoints.get()); + assertNormalEffectsOnce(correlation, OCCURRED_AT); + verify(cache, times(2)).reserveWithOutcome(correlation); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void targetedWireIdentityRemainsExplicitButDoesNotEnterTheOwnerLedger() throws Exception { + UUID id = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.voteOnline("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "", id, false, false, 1, 1), ignored -> { }); + assertTrue(accepted.get(0).isTargetedProxyVote()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + @Test + void canonicalTestFlagRemainsExcludedAcrossRouterAndAcceptedListener() throws Exception { + UUID id = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, false, "", id, false, false, 1, 1), ignored -> { }); + assertFalse(accepted.get(0).isRealVote()); + assertFalse(posted.get(0).isRealVote()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + verify(user).playerVote(site, true, true); + verify(user, never()).addTotal(); + verify(user).addPoints(POINTS); // Preserve the existing router's per-server points policy. + } + + @Test + void missingOriginalTimeIsNotMadeCanonicalByNormalPostVoteTimestampFallback() throws Exception { + UUID id = UUID.randomUUID(); + long before = System.currentTimeMillis(); + router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, 0, + true, true, "", id, false, false, 1, 1), ignored -> { }); + assertEquals(0, accepted.get(0).getTime()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertTrue(posted.get(0).getVoteTime() >= before); + assertTrue(posted.get(0).getVoteTime() <= System.currentTimeMillis()); + assertEquals(0, progress("october").votes()); + verify(user).setTime(site); + } + + @Test + void explicitWireIdOverridesTotalsCorrelationThroughDateAccountingAndPostVote() throws Exception { + UUID wireId = UUID.randomUUID(), correlation = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote( + "Player", PLAYER.toString(), SERVICE, OCCURRED_AT, true, true, + "v2//1//2//3//4//5//6//7//8//" + correlation, wireId, false, false, 1, 1)); + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertEquals(wireId, accepted.get(0).getProxyVoteId()); + assertEquals(wireId, posted.get(0).getProxyVoteId()); + assertEquals(wireId, posted.get(0).getVoteUUID()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(1, progress("october").votes()); + assertNormalEffectsOnce(wireId, OCCURRED_AT); + verify(cache, times(2)).reserveWithOutcome(wireId); + verify(cache, never()).reserveWithOutcome(correlation); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index 3c018e53f3..1ebe31f1e8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -195,6 +195,7 @@ void actualAccountingDispatchFailureQuarantinesOriginalIdAndNeverReplaysNormalPr org.mockito.Mockito.doAnswer(call -> { ((com.bencodez.votingplugin.events.PlayerVoteEvent) call.getArgument(0)).setDateMilestoneAccountingFailed(true); return null; }) .when(dispatcher).callEvent(any()); when(plugin.getBungeeSettings()).thenReturn(mock(BungeeSettings.class)); + when(plugin.getServerData()).thenReturn(mock(ServerData.class)); when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER_UUID, "Player")).thenReturn(user); org.mockito.Mockito.doAnswer(call -> { dispatchUser.bungeeVotePluginMessaging(call.getArgument(0), call.getArgument(1), call.getArgument(2), @@ -209,6 +210,7 @@ void actualAccountingDispatchFailureQuarantinesOriginalIdAndNeverReplaysNormalPr assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); outcome.set(null); router.handleOrderedVote(envelope, outcome::set); assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); verify(cache, never()).complete(voteId); verify(dispatchPlugin.getServer().getPluginManager(), times(1)).callEvent(any()); + verify(plugin.getServerData(), times(1)).addServiceSite("known.example"); } @Test diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 723360a98d..e66cab5999 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -62,6 +62,6 @@ Runtime reward aliases register from canonical milestone reward sections indepen Proxy transports open only after the existing milestone, streak, vote-party, special-reward, top-voter, shop and placeholder handlers have initialized and reward registration has finished. Date definitions and reward aliases are published before that ingress opens. -If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. +If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The router finishes its remaining normal per-server points mutation and service-site observation before surfacing the date-accounting failure. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. -Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. Reliable multi-proxy delivery retains the original wire timestamp through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older origin-less routes retain their legacy behavior and remain excluded from canonical date-event accounting. +Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. `PlayerVoteEvent.proxyVoteId` and `PlayerPostVoteEvent.proxyVoteId` preserve the original explicit wire ID (or remain null when absent). Legacy totals correlation remains the effective identity for normal milestones, streaks, post-vote `voteUUID`, and backend replay fencing. Reliable multi-proxy delivery retains the original wire timestamp through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older origin-less routes retain their legacy behavior and remain excluded from canonical date-event accounting. From 57ebcbc72da5cb0330177a056cbb5aeec670ff7e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:13:58 -0600 Subject: [PATCH 09/22] Gate local date ingress and validate ordered progress filters --- .../votingplugin/VotingPluginMain.java | 21 +++++---- .../datemilestones/DateVoteMilestones.java | 6 ++- .../DateVoteIngressOrderingTest.java | 47 +++++++++++++++++++ .../DateVoteMilestonesTest.java | 33 ++++++++++++- docs/date-vote-milestones.md | 2 + 5 files changed, 97 insertions(+), 12 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 2d21719387..3b32106fa6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -684,14 +684,16 @@ private void metrics() { new VotingPluginMetrics().load(plugin); } - /** Publish proxy ingress only after the complete accepted-vote pipeline is initialized. */ + /** Publish accepted and transport ingress only after the complete vote pipeline is initialized. */ void initializeDateVoteIngress(Runnable openProxy) { if (voteMilestonesManager == null || voteStreakHandler == null || voteParty == null || specialRewards == null || topVoterHandler == null || voteShopManager == null || placeholders == null) { - throw new IllegalStateException("Accepted-vote handlers must initialize before proxy ingress"); + throw new IllegalStateException("Accepted-vote handlers must initialize before transport ingress"); } getDateVoteMilestones().reload(); + getServer().getPluginManager().registerEvents(new PlayerVoteListener(this), this); if (getBungeeSettings().isUseBungeecoord()) openProxy.run(); + registerLocalVotifierIngress(); } @Override @@ -2041,19 +2043,20 @@ private void registerCommands() { } - /** - * Register events. - */ - private void registerEvents() { + /** Local ingress is registered only after accepted-vote handlers and date definitions are ready. */ + private void registerLocalVotifierIngress() { PluginManager pm = getServer().getPluginManager(); - - pm.registerEvents(new PlayerJoinEvent(this), this); if (isVotifierLoaded()) { VotiferEvent votifierEvent = new VotiferEvent(this); votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote); pm.registerEvents(votifierEvent, this); } - pm.registerEvents(new PlayerVoteListener(this), this); + } + + private void registerEvents() { + PluginManager pm = getServer().getPluginManager(); + + pm.registerEvents(new PlayerJoinEvent(this), this); pm.registerEvents(new PlayerPostVoteLoggerListener(this), this); pm.registerEvents(new PlayerSpecialRewardLoggerListener(this), this); pm.registerEvents(new VoteShopPurchaseLoggerListener(this), this); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java index cb5259da2b..b898d8f786 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -104,6 +104,10 @@ public static DateVoteEvent parse(String id, ConfigurationSection section) { throw new IllegalArgumentException("Each numeric milestone must contain Rewards"); thresholds.add(threshold); } + Object rawSites = section.get("VoteSites"); + if (rawSites != null && (!(rawSites instanceof List sites) + || sites.stream().anyMatch(site -> !(site instanceof String)))) + throw new IllegalArgumentException("VoteSites must be a list of configured site keys"); return new DateVoteEvent(id, section.getString("DisplayName", id), section.getBoolean("Enabled", false), DateVoteEvent.timestamp(start, zone), DateVoteEvent.timestamp(end, zone), zone, thresholds, new java.util.HashSet<>(section.getStringList("VoteSites")), section.getString("AccountingServer", "")); @@ -163,7 +167,7 @@ public void progress(Player player) { } List snapshot = definitions; try { - plugin.getTimer().execute(() -> { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { try { if (snapshot != definitions || !plugin.isEnabled()) return; UUID storageId = snapshot.isEmpty() ? uuid diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java index 591bf551ef..a2c542e85b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -49,6 +49,53 @@ private static void ready(VotingPluginMain plugin) throws Exception { doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); Runnable ingress = mock(Runnable.class); assertThrows(IllegalStateException.class, () -> plugin.initializeDateVoteIngress(ingress)); verify(ingress, never()).run(); verify(plugin.getDateVoteMilestones(), never()).reload(); + verify(plugin.getServer().getPluginManager(), never()).registerEvents(any(), eq(plugin)); + } + } + + @Test void localVotifierAndAcceptedListenersAreRegisteredOnlyAfterDefinitionsAndHandlers() throws Exception { + for (boolean proxyEnabled : new boolean[] {false, true}) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + YamlConfiguration config = new YamlConfiguration(); + String prefix = "DateVoteMilestones.local."; + config.set(prefix + "Enabled", true); config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "Start", "2020-01-01T00:00:00"); config.set(prefix + "End", "2099-01-01T00:00:00"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.isVotifierLoaded()).thenReturn(true); + DateVoteMilestones events = new DateVoteMilestones(plugin); + when(plugin.getDateVoteMilestones()).thenReturn(events); + VotingPluginUser user = mock(VotingPluginUser.class); when(user.getJavaUUID()).thenReturn(UUID.randomUUID()); + var listeners = new java.util.ArrayList(); + var pluginManager = plugin.getServer().getPluginManager(); + doAnswer(invocation -> { + verify(plugin).addDirectlyDefinedRewards(any()); + listeners.add(invocation.getArgument(0)); + if (invocation.getArgument(0) instanceof com.bencodez.votingplugin.listeners.VotiferEvent) + assertTrue(events.accepted(user, "a", UUID.randomUUID(), System.currentTimeMillis(), true, proxyEnabled, proxyEnabled, false, false)); + return null; + }).when(pluginManager).registerEvents(any(), eq(plugin)); + ready(plugin); doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(proxyEnabled); + when(plugin.getBungeeSettings().getServer()).thenReturn("backend"); + config.set(prefix + "AccountingServer", "backend"); + var opened = new java.util.concurrent.atomic.AtomicBoolean(); + Runnable proxy = () -> { + assertEquals(1, listeners.size()); + assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, listeners.getFirst()); + opened.set(true); + }; + try (var overflow = mockConstruction(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class)) { + plugin.initializeDateVoteIngress(proxy); + assertEquals(1, overflow.constructed().size()); + } + assertEquals(2, listeners.size()); + assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, listeners.get(0)); + assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, listeners.get(1)); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + assertEquals(proxyEnabled, opened.get()); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java index 3fc6cdb939..435496f8da 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -259,16 +259,21 @@ private String rewardPath(String id) { when(plugin.isEnabled()).thenReturn(true); java.util.Queue worker = new java.util.ArrayDeque<>(), entity = new java.util.ArrayDeque<>(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); - when(plugin.getTimer()).thenReturn(timer); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); doAnswer(invocation -> { worker.add(invocation.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); try (var scheduler = mockStatic(com.bencodez.votingplugin.util.BukkitCompletionScheduler.class)) { scheduler.when(() -> com.bencodez.votingplugin.util.BukkitCompletionScheduler.run(eq(plugin), eq(playerEntity), any(Runnable.class), any(Runnable.class), any(Runnable.class))).thenAnswer(invocation -> { entity.add(invocation.getArgument(2)); return null; }); + worker.add(() -> when(user.getJavaUUID()).thenReturn(storageId)); + when(user.getJavaUUID()).thenReturn(player); // Earlier ordered mutation has not yet run. m.progress(playerEntity); entity.remove().run(); for (int i = 0; i < 100; i++) { m.progress(playerEntity); entity.remove().run(); } - assertEquals(1, worker.size()); + assertEquals(2, worker.size()); + verify(plugin, never()).getTimer(); + verify(plugin.getVotingPluginUserManager(), never()).getVotingPluginUser(any(UUID.class), anyString()); + worker.remove().run(); // The prior mutation must precede the progress lookup. verify(playerEntity, never()).sendMessage(startsWith("october (")); worker.remove().run(); assertEquals(1, entity.size()); entity.remove().run(); verify(playerEntity).sendMessage(contains(": 1 votes;")); @@ -280,6 +285,30 @@ private String rewardPath(String id) { verify(playerEntity, never()).sendMessage(startsWith("october (")); } } + @Test void malformedSiteFilterDisablesOnlyThatDefinitionAndPreservesQueuedRewardHandles() { + for (Object malformed : new Object[] {"SiteA", 42, java.util.Map.of("SiteA", true), java.util.List.of("SiteA", 42)}) { + event("badfilter", ""); event("validfilter", ""); + config.set("DateVoteMilestones.badfilter.VoteSites", malformed); + var manager = manager(); + assertThrows(IllegalArgumentException.class, () -> DateVoteMilestones.parse("badfilter", config.getConfigurationSection("DateVoteMilestones.badfilter"))); + var handles = org.mockito.ArgumentCaptor.forClass(com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward.class); + verify(plugin, atLeastOnce()).addDirectlyDefinedRewards(handles.capture()); + assertTrue(handles.getAllValues().stream().anyMatch(h -> h.getPath().contains(com.bencodez.votingplugin.core.datemilestones.DateVoteEvent.fileId("badfilter")))); + manager.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), any(), contains(com.bencodez.votingplugin.core.datemilestones.DateVoteEvent.fileId("badfilter")), any()); + verify(plugin.getRewardHandler(), atLeastOnce()).giveReward(eq(user), any(), eq(rewardPath("validfilter")), any()); + } + } + @Test void absentEmptyAndExplicitStringSiteFiltersKeepTheirDocumentedScope() { + event("filters", ""); var section = config.getConfigurationSection("DateVoteMilestones.filters"); + assertTrue(DateVoteMilestones.parse("filters", section).matches(time, "a", true, false, "")); + section.set("VoteSites", java.util.List.of()); + assertTrue(DateVoteMilestones.parse("filters", section).matches(time, "b", true, false, "")); + section.set("VoteSites", java.util.List.of("a")); + var filtered = DateVoteMilestones.parse("filters", section); + assertTrue(filtered.matches(time, "a", true, false, "")); assertFalse(filtered.matches(time, "b", true, false, "")); + } + private long countPlayerRecords(Path directory) { try (var files = Files.list(directory)) { return files.filter(p -> !p.getFileName().toString().equals("definitions.properties")).count(); diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index e66cab5999..0b5c7573b0 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -65,3 +65,5 @@ Proxy transports open only after the existing milestone, streak, vote-party, spe If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The router finishes its remaining normal per-server points mutation and service-site observation before surfacing the date-accounting failure. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. `PlayerVoteEvent.proxyVoteId` and `PlayerPostVoteEvent.proxyVoteId` preserve the original explicit wire ID (or remain null when absent). Legacy totals correlation remains the effective identity for normal milestones, streaks, post-vote `voteUUID`, and backend replay fencing. Reliable multi-proxy delivery retains the original wire timestamp through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older origin-less routes retain their legacy behavior and remain excluded from canonical date-event accounting. + +Local Votifier and accepted-vote listeners are registered after date definitions and all normal reward handlers are ready, alongside the existing proxy-ingress readiness boundary. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. From 897f386f812a7570c4a9eb313d188a8c02bf5a8d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 02:32:04 -0600 Subject: [PATCH 10/22] Start recovered date vote replay after its consumer is ready --- .../votingplugin/VotingPluginMain.java | 11 ++++---- .../DateVoteIngressOrderingTest.java | 25 ++++++++++++++++--- docs/date-vote-milestones.md | 2 ++ 3 files changed, 29 insertions(+), 9 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 3b32106fa6..5461a57517 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -692,6 +692,11 @@ void initializeDateVoteIngress(Runnable openProxy) { } getDateVoteMilestones().reload(); getServer().getPluginManager().registerEvents(new PlayerVoteListener(this), this); + // Construction itself schedules persisted replay. Open it only after its consumer. + if (!getBungeeSettings().isUseBungeecoord() || !getBungeeSettings().isGloblalDataEnabled()) { + timeQueueHandler = new TimeQueueHandler(this); + getServer().getPluginManager().registerEvents(timeQueueHandler, this); + } if (getBungeeSettings().isUseBungeecoord()) openProxy.run(); registerLocalVotifierIngress(); } @@ -730,9 +735,6 @@ public void onPostLoad() { // Proxy ingress opens below, after every accepted-vote handler is initialized. - if (!bungeeSettings.isUseBungeecoord() || !bungeeSettings.isGloblalDataEnabled()) { - this.timeQueueHandler = new TimeQueueHandler(this); - } if (Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null) { if (getConfigFile().isLoadInteralExpansion()) { @@ -2070,9 +2072,6 @@ private void registerEvents() { pm.registerEvents(new PlayerInteract(this), this); } - if (timeQueueHandler != null) { - pm.registerEvents(timeQueueHandler, plugin); - } pm.registerEvents(new VotingPluginUpdateEvent(this), this); /* diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java index a2c542e85b..4e32c60be7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -83,7 +83,7 @@ private static void ready(VotingPluginMain plugin) throws Exception { config.set(prefix + "AccountingServer", "backend"); var opened = new java.util.concurrent.atomic.AtomicBoolean(); Runnable proxy = () -> { - assertEquals(1, listeners.size()); + assertEquals(2, listeners.size()); assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, listeners.getFirst()); opened.set(true); }; @@ -91,12 +91,31 @@ private static void ready(VotingPluginMain plugin) throws Exception { plugin.initializeDateVoteIngress(proxy); assertEquals(1, overflow.constructed().size()); } - assertEquals(2, listeners.size()); + assertEquals(3, listeners.size()); assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, listeners.get(0)); - assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, listeners.get(1)); + assertInstanceOf(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, listeners.get(1)); + assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, listeners.get(2)); verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any()); assertEquals(proxyEnabled, opened.get()); } } + @Test void persistedReplayProducerIsConstructedOnlyAfterItsAcceptedVoteConsumer() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); ready(plugin); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + var manager = plugin.getServer().getPluginManager(); + var registered = new java.util.ArrayList(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin)); + try (var producers = mockConstruction(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, (handler, context) -> { + // The real constructor schedules replay immediately; this hook runs at that boundary. + assertEquals(1, registered.size()); assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, registered.getFirst()); + verify(plugin.getDateVoteMilestones()).reload(); + })) { + plugin.initializeDateVoteIngress(() -> fail("Local startup does not open proxy ingress")); + assertEquals(1, producers.constructed().size()); assertSame(producers.constructed().getFirst(), registered.get(1)); + var field = VotingPluginMain.class.getDeclaredField("timeQueueHandler"); field.setAccessible(true); + assertSame(producers.constructed().getFirst(), field.get(plugin)); + } + } + } diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 0b5c7573b0..94f47ae959 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -67,3 +67,5 @@ If canonical proxy occurrence accounting cannot be durably confirmed, the accept Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. `PlayerVoteEvent.proxyVoteId` and `PlayerPostVoteEvent.proxyVoteId` preserve the original explicit wire ID (or remain null when absent). Legacy totals correlation remains the effective identity for normal milestones, streaks, post-vote `voteUUID`, and backend replay fencing. Reliable multi-proxy delivery retains the original wire timestamp through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older origin-less routes retain their legacy behavior and remain excluded from canonical date-event accounting. Local Votifier and accepted-vote listeners are registered after date definitions and all normal reward handlers are ready, alongside the existing proxy-ingress readiness boundary. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. + +Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. From 1535e27606a2b16a7d188e8cc63f322f0ba74c52 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:03:13 -0600 Subject: [PATCH 11/22] Separate date occurrence provenance from normal vote processing time --- .../messaging/BackendProxyMessageRouter.java | 3 +- .../events/PlayerPostVoteEvent.java | 5 + .../votingplugin/events/PlayerVoteEvent.java | 5 + .../listeners/PlayerVoteListener.java | 17 +- .../votingplugin/proxy/OfflineBungeeVote.java | 13 ++ .../proxy/VoteOccurrenceMetadata.java | 41 ++++ .../votingplugin/proxy/VotingPluginProxy.java | 106 +++++++--- .../votingplugin/proxy/VotingPluginWire.java | 24 ++- .../cache/ProxyOnlineVoteCacheTable.java | 2 + .../proxy/cache/ProxyVoteCacheTable.java | 2 + .../proxy/multiproxy/MultiProxyHandler.java | 18 +- .../datemilestones/DateVoteMilestones.java | 30 +++ .../votingplugin/timequeue/VoteTimeQueue.java | 13 ++ .../votingplugin/user/VotingPluginUser.java | 9 + .../util/BukkitCompletionScheduler.java | 13 ++ ...ckendDateVoteAccountingRegressionTest.java | 185 +++++++++++++++++- .../BackendProxyMessageRouterTest.java | 20 +- .../PlayerVoteListenerProxyVoteIdTest.java | 21 ++ .../MultiProxyHandlerLifecycleTest.java | 8 +- .../DateVoteMilestonesTest.java | 46 +++++ .../tests/VoteCacheHandlerVoteIdTest.java | 64 ++++++ .../tests/VoteOccurrenceMetadataTest.java | 63 ++++++ .../tests/VotingPluginProxyTest.java | 16 +- docs/date-vote-milestones.md | 8 +- 24 files changed, 677 insertions(+), 55 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java index bff6a34778..7f55c852bf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouter.java @@ -488,7 +488,8 @@ private WireVoteResult handleWireVote(JsonEnvelope msg) { user.bungeeVotePluginMessaging(vote.service, vote.time, totals, !vote.manageTotals, wasOnline, vote.broadcast, vote.num, queuedDelivery, vote.delayValidationKnown, vote.queuedDeliveryKnown, vote.voteId, - VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote); + VotingPluginWire.SUB_VOTE_ONLINE.equals(msg.getSubChannel()), vote.realVote, + vote.canonicalOccurrenceTime); } catch (VotingPluginUser.DateMilestoneAccountingException incomplete) { dateAccountingFailure = incomplete; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java index 37c95737db..d68eba18bd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java @@ -13,6 +13,11 @@ import lombok.Setter; public class PlayerPostVoteEvent extends Event { + + /** Original accepted occurrence time; ordinary cooldown/event time remains separate. */ + @Getter + @Setter + private Long canonicalOccurrenceTime; /** The Constant handlers. */ private static final HandlerList handlers = new HandlerList(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 65179491b4..34cd2e9085 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -13,6 +13,11 @@ import lombok.Setter; public class PlayerVoteEvent extends Event { + + /** Original accepted occurrence time; ordinary cooldown/event time remains separate. */ + @Getter + @Setter + private Long canonicalOccurrenceTime; /** The Constant handlers. */ private static final HandlerList handlers = new HandlerList(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 93582ca73b..5293a8f576 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -174,9 +174,18 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { voteId, new HashMap()); } @Override public void dateMilestones(VotingPluginUser user, VoteSite site, long voteTime, UUID voteId) { - if (!event.isAsynchronous()) return; // No persistence from a gameplay event context. - if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, voteTime, realVote(), proxyVote(), - event.getProxyVoteId() != null && incomingTime() > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true); + long occurrenceTime = event.getCanonicalOccurrenceTime() == null + ? proxyVote() ? incomingTime() : voteTime : event.getCanonicalOccurrenceTime(); + if (com.bencodez.votingplugin.util.BukkitCompletionScheduler.isPlatformOwnedThread()) { + // An asynchronous event flag does not identify the thread that dispatched it. + // A proxy caller cannot acknowledge accounting that has only been admitted. + event.setDateMilestoneAccountingFailed(!plugin.getDateVoteMilestones().deferAccepted(user, site.getKey(), voteId, occurrenceTime, + realVote(), proxyVote(), event.getProxyVoteId() != null && occurrenceTime > 0, + targetedProxyVote(), forceProxyRouting())); + return; + } + if (!plugin.getDateVoteMilestones().accepted(user, site.getKey(), voteId, occurrenceTime, realVote(), proxyVote(), + event.getProxyVoteId() != null && occurrenceTime > 0, targetedProxyVote(), forceProxyRouting())) event.setDateMilestoneAccountingFailed(true); } @Override public void cooldown(VotingPluginUser user, VoteSite site) { plugin.getCoolDownCheck().vote(user, site); } @Override public void voteStreak(VotingPluginUser user, long voteTime, UUID voteId) { @@ -187,6 +196,8 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { PlayerPostVoteEvent post = new PlayerPostVoteEvent(site, user, event.isRealVote(), event.isForceBungee(), voteTime, cached, site.getServiceSite(), user.getJavaUUID(), playerName, voteId); post.setProxyVoteId(event.getProxyVoteId()); + post.setCanonicalOccurrenceTime(event.getCanonicalOccurrenceTime() == null + ? event.isBungee() ? event.getTime() : voteTime : event.getCanonicalOccurrenceTime()); plugin.getServer().getPluginManager().callEvent(post); } @Override public boolean placeholderCacheAlways() { return plugin.getConfigFile().getPlaceholderCacheLevel().isCacheAlways(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java index 3b11d03475..20845cbafb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java @@ -40,6 +40,19 @@ public class OfflineBungeeVote { @Getter @Setter private String text; + + public long getCanonicalOccurrenceTime() { + Long original = VoteOccurrenceMetadata.read(text); + return original == null ? 0L : original; + } + + public void setCanonicalOccurrenceTime(long occurredAt) { + text = VoteOccurrenceMetadata.store(text, occurredAt); + } + + public void setText(String updated) { + text = VoteOccurrenceMetadata.preserve(text, updated); + } @Getter private long time; @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java new file mode 100644 index 0000000000..433b04013f --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VoteOccurrenceMetadata.java @@ -0,0 +1,41 @@ +package com.bencodez.votingplugin.proxy; + +/** Optional occurrence metadata in existing opaque cache payloads, separate from cooldown time. */ +public final class VoteOccurrenceMetadata { + private static final String MARKER = "//vp-occurrence-time:"; + + private VoteOccurrenceMetadata() { } + + /** Null means an older payload without metadata; -1 means explicitly invalid metadata. */ + public static Long read(String payload) { + if (payload == null) return null; + int marker = payload.lastIndexOf(MARKER); + if (marker < 0) return null; + if (marker != payload.indexOf(MARKER)) return -1L; + return parse(payload.substring(marker + MARKER.length(), + Math.min(payload.length(), marker + MARKER.length() + 21))); + } + + public static long parse(String raw) { + if (raw == null || raw.isEmpty() || raw.length() > 20) return -1L; + try { + long time = Long.parseLong(raw); + return time > 0 ? time : -1L; + } catch (NumberFormatException invalid) { + return -1L; + } + } + + /** Existing v1/v2 totals parsers ignore this tagged trailing token. */ + public static String store(String payload, long occurredAt) { + String text = payload == null ? "" : payload; + int marker = text.indexOf(MARKER); + if (marker >= 0) text = text.substring(0, marker); + return text + MARKER + (occurredAt > 0 ? occurredAt : -1L); + } + + public static String preserve(String oldPayload, String updatedPayload) { + Long original = read(oldPayload); + return original == null ? updatedPayload : store(updatedPayload, original); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java index 3487ec00e5..aafc2a0dbc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -165,6 +165,7 @@ private static final class LiveVoteRetryState { private String service; private String uuid; private long time; + private long canonicalOccurrenceTime; private boolean realVote; private boolean playerOnline; private boolean delayValidated; @@ -184,6 +185,7 @@ private enum Phase { private final boolean realVote; private final boolean timeQueue; private final long queueTime; + private final long canonicalOccurrenceTime; private final VoteTotalsSnapshot totals; private final String uuid; private final UUID voteId; @@ -196,7 +198,8 @@ private enum Phase { private MultiProxyVoteRetry(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID voteId, String origin, boolean delayValidated, - boolean delayValidationKnown) { + boolean delayValidationKnown, long canonicalOccurrenceTime) { + this.canonicalOccurrenceTime = canonicalOccurrenceTime; this.player = player; this.service = service; this.realVote = realVote; @@ -2726,7 +2729,8 @@ public void setEncryptionHandler(EncryptionHandler encryptionHandler1) { @Override public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid) { - vote(player, service, realVote, timeQueue, queueTime, text, uuid); + // Origin-less forwarding cannot establish an original canonical occurrence. + vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, null, false, false, -1L); } @Override @@ -2749,6 +2753,14 @@ public void triggerVote(String player, String service, boolean realVote, boolean delayValidated, delayValidationKnown); } + @Override + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, String origin, boolean delayValidated, + boolean delayValidationKnown, long canonicalOccurrenceTime) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin, + delayValidated, delayValidationKnown, canonicalOccurrenceTime); + } + @Override public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) { handleMultiProxyVoteAcknowledgement(voteId, recipient); @@ -2785,6 +2797,13 @@ protected synchronized void receiveMultiProxyVote(String player, String service, protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin, boolean delayValidated, boolean delayValidationKnown) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, totals, uuid, wireVoteId, origin, + delayValidated, delayValidationKnown, queueTime); + } + + protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, + boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin, + boolean delayValidated, boolean delayValidationKnown, long canonicalOccurrenceTime) { UUID voteId = wireVoteId == null ? UUID.randomUUID() : wireVoteId; if (completedMultiProxyVotes.containsKey(voteId)) { acknowledgeCompletedMultiProxyVote(voteId, origin); @@ -2803,7 +2822,7 @@ protected synchronized void receiveMultiProxyVote(String player, String service, // this process heap and is loaded again after a restart. It also lets the // normal queue processor drain the spill as retry slots become available. if (retainForwardedVoteOverflow(player, service, realVote, totals, uuid, voteId, queueTime, origin, - delayValidated, delayValidationKnown)) { + delayValidated, delayValidationKnown, canonicalOccurrenceTime)) { scheduleTimeVoteRetry(); return; } @@ -2811,7 +2830,7 @@ protected synchronized void receiveMultiProxyVote(String player, String service, return; } retry = new MultiProxyVoteRetry(player, service, realVote, timeQueue, queueTime, totals, uuid, voteId, origin, - delayValidated, delayValidationKnown); + delayValidated, delayValidationKnown, canonicalOccurrenceTime); multiProxyVoteRetries.put(voteId, retry); } if (!retry.scheduled) attemptMultiProxyVote(retry); @@ -2820,7 +2839,7 @@ protected synchronized void receiveMultiProxyVote(String player, String service, /** Durably spills an over-capacity forwarded vote into the normal replay queue. */ private boolean retainForwardedVoteOverflow(String player, String service, boolean realVote, VoteTotalsSnapshot totals, String uuid, UUID voteId, long queueTime, String origin, boolean delayValidated, - boolean delayValidationKnown) { + boolean delayValidationKnown, long canonicalOccurrenceTime) { if (voteId == null || player == null || service == null || uuid == null) return false; for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) { if (voteId.equals(queued.getVoteId())) return true; @@ -2828,6 +2847,7 @@ private boolean retainForwardedVoteOverflow(String player, String service, boole long time = queueTime == 0L ? System.currentTimeMillis() : queueTime; VoteTimeQueue queued = new VoteTimeQueue(voteId, player, service, time, false, Collections.emptySet(), Collections.emptySet(), totals == null ? "" : totals.toString(), false, uuid); + queued.setCanonicalOccurrenceTime(canonicalOccurrenceTime); queued.setRealVote(realVote); queued.setMultiProxyOrigin(origin == null ? "" : origin); if (delayValidationKnown) queued.setDelayValidated(delayValidated); @@ -2905,6 +2925,7 @@ private synchronized void attemptMultiProxyVote(MultiProxyVoteRetry retry, boole private boolean persistDeferredMultiProxyReceipt(MultiProxyVoteRetry retry) { VoteTimeQueue queued = findUnprocessedQueuedVote(retry.voteId); if (queued == null) return false; + queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); queued.setMultiProxyOrigin(retry.origin); queued.setRealVote(retry.realVote); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); @@ -3451,6 +3472,7 @@ private boolean retainCompletedMultiProxyRetry(VoteCacheHandler cache, MultiProx // completion fence: its origin is persisted by PERSIST_DEFERRED_RECEIPT. if (!queued.isProcessed()) return false; queued.setProcessed(true); + queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); queued.setRealVote(retry.realVote); queued.setMultiProxyOrigin(retry.origin); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); @@ -3462,6 +3484,7 @@ private boolean retainCompletedMultiProxyRetry(VoteCacheHandler cache, MultiProx retry.queueTime == 0L ? System.currentTimeMillis() : retry.queueTime, false, Collections.emptySet(), Collections.emptySet(), retry.totals == null ? "" : retry.totals.toString(), true, retry.uuid); + quarantine.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); quarantine.setRealVote(retry.realVote); quarantine.setMultiProxyOrigin(retry.origin); if (retry.delayValidationKnown) quarantine.setDelayValidated(retry.delayValidated); @@ -5678,22 +5701,25 @@ private boolean sendVoteDelayRejected(UUID voteId, String player, String uuid, S private JsonEnvelope cachedVoteEnvelope(OfflineBungeeVote vote, boolean online, boolean broadcast, int num, int numberOfVotes) { + // Older cache rows may have been timestamped by an intermediate receiver. + // Preserve their normal time, but do not invent date occurrence provenance. + String payload = VoteOccurrenceMetadata.store(vote.getText(), vote.getCanonicalOccurrenceTime()); JsonEnvelope envelope; if (vote.isDelayValidationKnown()) { envelope = online ? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated()) : VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes, vote.isDelayValidated()); } else { envelope = online ? VotingPluginWire.voteOnline(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes) : VotingPluginWire.vote(vote.getPlayerName(), vote.getUuid(), vote.getService(), vote.getTime(), - resolveCachedWasOnline(vote), vote.isRealVote(), vote.getText(), vote.getVoteId(), + resolveCachedWasOnline(vote), vote.isRealVote(), payload, vote.getVoteId(), getConfig().getBungeeManageTotals(), broadcast, num, numberOfVotes); } // A cache entry is only known to have passed the proxy's delay gate when @@ -5879,6 +5905,7 @@ public synchronized VoteTimeQueue snapshotIncomingVoteForRestart(PendingIncoming queued.setMultiProxyForwardingHandled(retry.multiProxyForwardingHandled); if (retry.delayValidationKnown) queued.setDelayValidated(retry.delayValidated); if (retry.totals != null) queued.setTotals(retry.totals.toString()); + if (retry.canonicalOccurrenceTime != 0L) queued.setCanonicalOccurrenceTime(retry.canonicalOccurrenceTime); } return queued; } @@ -5944,6 +5971,13 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId, boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown) { + return vote(player, service, realVote, timeQueue, queueTime, text, uuid, queuedVote, requestedVoteId, + upstreamDelayValidated, upstreamDelayValidationKnown, 0L); + } + + private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, + long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId, + boolean upstreamDelayValidated, boolean upstreamDelayValidationKnown, long upstreamOccurrenceTime) { try { String requestPlayer = player; if (!ServiceSiteValidator.isValid(service)) { @@ -6053,6 +6087,13 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea : (queueTime != 0 ? queueTime : LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + MultiProxyVoteRetry forwardedOccurrence = multiProxyVoteRetries.get(voteId); + long canonicalOccurrenceTime = retryState != null ? retryState.canonicalOccurrenceTime + : upstreamOccurrenceTime != 0L ? upstreamOccurrenceTime + : forwardedOccurrence != null ? forwardedOccurrence.canonicalOccurrenceTime + : queuedVote == null ? time + : queuedVote.getCanonicalOccurrenceTime() == 0L ? -1L : queuedVote.getCanonicalOccurrenceTime(); + Set broadcastTargets = queuedVote == null ? new LinkedHashSet<>() : new LinkedHashSet<>(queuedVote.getBroadcastTargets()); Set broadcastForwardedServers = queuedVote == null ? new LinkedHashSet<>() @@ -6122,6 +6163,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { VoteTimeQueue delayedVote = new VoteTimeQueue(voteId, player, service, time, proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, projectedTotals == null ? "" : projectedTotals.toString(), false, uuid); + if (canonicalOccurrenceTime != 0L) delayedVote.setCanonicalOccurrenceTime(canonicalOccurrenceTime); delayedVote.setRealVote(realVote); delayedVote.setWasOnline(playerOnline); if (authoritativeDelayValidationKnown) { @@ -6157,6 +6199,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { retryState.service = service; retryState.uuid = uuid; retryState.time = time; + retryState.canonicalOccurrenceTime = canonicalOccurrenceTime; retryState.realVote = realVote; retryState.playerOnline = playerOnline; retryState.delayValidated = authoritativeDelayValidated; @@ -6243,6 +6286,9 @@ && getGlobalDataHandler().isTimeChangedHappened()) { text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); } + String votePayload = canonicalOccurrenceTime == 0L ? text.toString() + : VoteOccurrenceMetadata.store(text.toString(), canonicalOccurrenceTime); + VoteLogStatus voteStatus = VoteLogStatus.IMMEDIATE; boolean standaloneProxyBroadcast = canValidateStandaloneBroadcast && (proxyBroadcastHandled || proxyBroadcastDecider.usesImmediateForwarding(playerOnline)); @@ -6264,7 +6310,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { standaloneBroadcastState = retryState.standaloneBroadcastState; if (standaloneBroadcastState == null) { standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, - text == null ? "" : text.toString(), false, true, proxyBroadcastTargets, + text == null ? "" : votePayload, false, true, proxyBroadcastTargets, broadcastForwardedServers, !getConfig().getSendVotesToAllServers(), Collections.emptyMap(), queuedVote == null ? Collections.emptyMap() : queuedVote.getHttpBroadcastDeliveryIds()); standaloneBroadcastState.setWasOnline(playerOnline); @@ -6309,7 +6355,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { OfflineBungeeVote rewardJournalOwner = retryState.rewardJournalOwner; if (rewardJournalOwner == null) { rewardJournalOwner = createCachedRewardVote(voteId, player, uuid, service, time, realVote, - text.toString(), false, playerOnline); + votePayload, false, playerOnline); if (retryState.delayValidationKnown) rewardJournalOwner.setDelayValidated(authoritativeDelayValidated); markRewardJournalTargets(rewardJournalOwner, rewardServers); retryState.rewardJournalOwner = rewardJournalOwner; @@ -6325,7 +6371,7 @@ && getGlobalDataHandler().isTimeChangedHappened()) { OfflineBungeeVote rewardState = retryState.rewardStates.get(server.toLowerCase(Locale.ROOT)); if (rewardState == null) { rewardState = createCachedRewardVote(voteId, player, uuid, service, time, - realVote, text.toString(), standaloneProxyBroadcast, playerOnline); + realVote, votePayload, standaloneProxyBroadcast, playerOnline); if (retryState.delayValidationKnown) rewardState.setDelayValidated(authoritativeDelayValidated); retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), rewardState); } @@ -6378,10 +6424,10 @@ && getGlobalDataHandler().isTimeChangedHappened()) { OfflineBungeeVote pendingVote = retryState.rewardStates.get(s.toLowerCase(Locale.ROOT)); JsonEnvelope rewardEnvelope = retryState.delayValidationKnown - ? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(), + ? VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, authoritativeDelayValidated) - : VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, text.toString(), + : VotingPluginWire.vote(player, uuid, service, time, playerOnline, realVote, votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1); boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, rewardEnvelope, pendingVote); if (!rewardAccepted) { @@ -6419,10 +6465,10 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, OfflineBungeeVote pendingVote = retryState.rewardStates.get(server.toLowerCase(Locale.ROOT)); if (pendingVote == null) { pendingVote = standaloneProxyBroadcast - ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false, + ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false, true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(), standaloneBroadcastState.getHttpBroadcastDeliveryIds()) - : createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline); + : createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline); retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), pendingVote); } pendingVote.setWasOnline(playerOnline); @@ -6431,10 +6477,10 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, if (!rewardAccepted) { JsonEnvelope rewardEnvelope = retryState.delayValidationKnown ? VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote, - text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, + votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, authoritativeDelayValidated) : VotingPluginWire.voteOnline(player, uuid, service, time, playerOnline, realVote, - text.toString(), voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1); + votePayload, voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1); rewardAccepted = sendVoteEnvelopeAccepted(server, 1, rewardEnvelope, pendingVote); if (rewardAccepted) retryState.deliveredRewardServers.add(server); } @@ -6479,7 +6525,7 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, } JsonEnvelope broadcast = VotingPluginWire.voteBroadcast(uuid, player, service, time, - text == null ? "" : text.toString(), true); + text == null ? "" : votePayload, true); if (method == BungeeMethod.HTTP) { String deliveryId = stableLiveHttpBroadcastDeliveryId(voteId, targetServer); if (!sendStableHttpEnvelope(targetServer, deliveryId, broadcast)) { @@ -6510,10 +6556,10 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, } } OfflineBungeeVote cachedReward = standaloneProxyBroadcast - ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false, + ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, votePayload, false, true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(), standaloneBroadcastState.getHttpBroadcastDeliveryIds()) - : createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false, playerOnline); + : createCachedRewardVote(voteId, player, uuid, service, time, realVote, votePayload, false, playerOnline); cachedReward.setWasOnline(playerOnline); if (retryState.delayValidationKnown) cachedReward.setDelayValidated(authoritativeDelayValidated); retryState.pendingOnlineRewardState = cachedReward; @@ -6534,7 +6580,7 @@ voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1, int delay = 2; for (String s : getAllAvailableServers()) { globalMessageProxyHandler.sendMessage(s, delay + 1, VotingPluginWire.voteUpdate(uuid, - votePartyVotes, currentVotePartyVotesRequired, service, time, text.toString())); + votePartyVotes, currentVotePartyVotesRequired, service, time, votePayload)); delay += 2; } } @@ -6665,6 +6711,10 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals, boolean delayValidated, boolean delayValidationKnown) { if (multiProxyHandler == null) return false; + String forwardingTotals = VoteOccurrenceMetadata.store(totals == null ? "" : totals.toString(), + retryState != null && retryState.canonicalOccurrenceTime != 0 + ? retryState.canonicalOccurrenceTime : queuedVote == null ? time + : queuedVote.getCanonicalOccurrenceTime()); // A corrupt or unpersistable peer-classification file cannot safely identify // which targets are legacy. Stop before creating an ACK outbox that a legacy // peer could never complete; the handler emits the operator recovery message. @@ -6714,12 +6764,12 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim // Older peers do not understand acknowledgements. Preserve their historical // fire-and-forget route instead of creating an outbox they can never ACK. return multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time, - false, realVote, totals == null ? "" : totals.toString(), findLiveVoteId(retryState), false, false, + false, realVote, forwardingTotals, findLiveVoteId(retryState), false, false, 1, 1), legacyRecipients); } if (outbox == null) { outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(), - Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid); + Collections.emptySet(), forwardingTotals, true, uuid); // The live retry key is the stable vote ID; copy it from the enclosing state // by locating its identity rather than creating a new duplicate record. outbox.setVoteId(findLiveVoteId(retryState)); @@ -6774,7 +6824,7 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim outbox.setDeliveryStateDirty(true); if (!persistTimeVoteDelivery(outbox)) return false; if (!multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(player, uuid, service, time, - false, realVote, totals == null ? "" : totals.toString(), outbox.getVoteId(), false, false, 1, 1), + false, realVote, forwardingTotals, outbox.getVoteId(), false, false, 1, 1), legacyPending)) { outbox.setMultiProxyLegacyPendingRecipients(legacyPending); outbox.setDeliveryStateDirty(true); @@ -6804,10 +6854,14 @@ private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTim private VoteTimeQueue admitMultiProxyDiscoveryOutbox(LiveVoteRetryState retryState, VoteTimeQueue queuedVote, String player, String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals, boolean delayValidated, boolean delayValidationKnown) { + String forwardingTotals = VoteOccurrenceMetadata.store(totals == null ? "" : totals.toString(), + retryState != null && retryState.canonicalOccurrenceTime != 0 + ? retryState.canonicalOccurrenceTime : queuedVote == null ? time + : queuedVote.getCanonicalOccurrenceTime()); VoteTimeQueue outbox = queuedVote; if (outbox == null) { outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(), - Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid); + Collections.emptySet(), forwardingTotals, true, uuid); outbox.setVoteId(findLiveVoteId(retryState)); if (outbox.getVoteId() == null) return null; retryState.queuedVote = outbox; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java index e19c113d14..65b164d4de 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -34,6 +34,7 @@ private VotingPluginWire() { } public static final int SCHEMA_VERSION = 1; + public static final String K_CANONICAL_OCCURRENCE_TIME = "canonicalOccurrenceTime"; // ========================= // Subchannels (canonical) @@ -177,13 +178,16 @@ public static JsonEnvelope voteOnline(String player, String uuid, String service private static JsonEnvelope.Builder voteBuilder(String subChannel, String player, String uuid, String service, long time, boolean wasOnline, boolean realVote, String totals, UUID voteId, boolean manageTotals, boolean bungeeBroadcast, int num, int numberOfVotes) { - return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) + JsonEnvelope.Builder builder = base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) .put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_WAS_ONLINE_KNOWN, true) .put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals)) .put(K_VOTE_ID, voteId == null ? "" : voteId.toString()).put(K_SET_TOTALS, true) .put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false) .put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num) .put(K_NUMBER_OF_VOTES, numberOfVotes); + Long occurrence = VoteOccurrenceMetadata.read(totals); + builder.put(K_CANONICAL_OCCURRENCE_TIME, occurrence == null ? time : occurrence); + return builder; } /** @@ -209,12 +213,15 @@ public static JsonEnvelope multiProxyVote(String player, String uuid, String ser private static JsonEnvelope.Builder multiProxyVoteBuilder(String subChannel, String player, String uuid, String service, long time, boolean wasOnline, boolean realVote, String totals, UUID voteId, boolean manageTotals, boolean bungeeBroadcast, int num, int numberOfVotes, String origin) { - return base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) + JsonEnvelope.Builder builder = base(subChannel).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)).put(K_SERVICE, safe(service)) .put(K_TIME, time).put(K_WAS_ONLINE, wasOnline).put(K_WAS_ONLINE_KNOWN, true).put(K_REAL_VOTE, realVote).put(K_TOTALS, safe(totals)) .put(K_VOTE_ID, voteId == null ? "" : voteId.toString()).put(K_SET_TOTALS, true) .put(K_MANAGE_TOTALS, manageTotals).put(K_QUEUED_DELIVERY, false) .put(K_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num) .put(K_NUMBER_OF_VOTES, numberOfVotes).put(K_MULTI_PROXY_ORIGIN, safe(origin)); + Long occurrence = VoteOccurrenceMetadata.read(totals); + builder.put(K_CANONICAL_OCCURRENCE_TIME, occurrence == null ? time : occurrence); + return builder; } /** Reliable multi-proxy variant of {@link #voteOnline}. */ @@ -633,6 +640,7 @@ public static final class Vote { public final String uuid; public final String service; public final long time; + public final long canonicalOccurrenceTime; public final boolean wasOnline; public final boolean wasOnlineKnown; @@ -654,12 +662,13 @@ private Vote(String subChannel, String player, String uuid, String service, long boolean wasOnlineKnown, boolean realVote, String totals, UUID voteId, boolean setTotals, boolean manageTotals, boolean delayValidated, boolean delayValidationKnown, boolean queuedDelivery, boolean queuedDeliveryKnown, - boolean broadcast, int num, int numberOfVotes) { + boolean broadcast, int num, int numberOfVotes, long canonicalOccurrenceTime) { this.subChannel = subChannel; this.player = player; this.uuid = uuid; this.service = service; this.time = time; + this.canonicalOccurrenceTime = canonicalOccurrenceTime; this.wasOnline = wasOnline; this.wasOnlineKnown = wasOnlineKnown; this.realVote = realVote; @@ -690,6 +699,13 @@ public static Vote readVote(JsonEnvelope env) { final boolean wasOnlineKnown = readBool(f, K_WAS_ONLINE_KNOWN, false); final boolean realVote = readBool(f, K_REAL_VOTE, false); final String totals = safe(f.get(K_TOTALS)); + Long cachedOccurrence = VoteOccurrenceMetadata.read(totals); + // Missing or malformed provenance excludes only date accounting. An older + // intermediate proxy may have replaced ordinary time with its receipt time. + long occurrence = f.containsKey(K_CANONICAL_OCCURRENCE_TIME) + ? VoteOccurrenceMetadata.parse(f.get(K_CANONICAL_OCCURRENCE_TIME)) + : cachedOccurrence == null ? -1L : cachedOccurrence; + if (cachedOccurrence != null && (cachedOccurrence <= 0 || cachedOccurrence != occurrence)) occurrence = -1L; final UUID voteId = readUuid(f, K_VOTE_ID); final boolean setTotals = readBool(f, K_SET_TOTALS, true); @@ -706,7 +722,7 @@ public static Vote readVote(JsonEnvelope env) { return new Vote(sub, player, uuid, service, time, wasOnline, wasOnlineKnown, realVote, totals, voteId, setTotals, manageTotals, delayValidated, delayValidationKnown, queuedDelivery, queuedDeliveryKnown, - broadcast, num, numberOfVotes); + broadcast, num, numberOfVotes, occurrence); } public static final class VoteDelayRejected { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java index a98195d9ec..74ccce3262 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyOnlineVoteCacheTable.java @@ -132,6 +132,8 @@ public void updateVoteText(OfflineBungeeVote vote, String newText) { return; } + newText = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(vote.getText(), newText); + String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("text") + " = ?" + " WHERE " + qi("uuid") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ?;"; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java index 9ed8bf3849..ba87b28443 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyVoteCacheTable.java @@ -97,6 +97,8 @@ public void updateVoteText(OfflineBungeeVote vote, String server, String newText return; } + newText = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(vote.getText(), newText); + String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("text") + " = ?" + " WHERE " + qi("uuid") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ? AND " + qi("server") + " = ?;"; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java index 870ba8b033..bb3e1b66bc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandler.java @@ -444,6 +444,14 @@ public void triggerVote(String player, String service, boolean realVote, boolean triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin); } + /** Additive occurrence metadata; queueTime remains the normal receiver clock convention. */ + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, String origin, boolean delayValidated, + boolean delayValidationKnown, long canonicalOccurrenceTime) { + triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin, + delayValidated, delayValidationKnown); + } + /** Called when a receiver acknowledges this proxy's stable vote ID. */ public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) { // Optional for legacy implementations. @@ -1276,8 +1284,14 @@ private void handleEnvelope(JsonEnvelope envelope) { if (origin.isBlank()) { triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid); } else { - triggerVote(player, service, realVote, true, wireVote.time, VoteTotalsSnapshot.parseStorage(totals), uuid, - wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown); + // Older reliable origin envelopes still carry the sender's original + // time. Keep it separate from receiver-local cooldown processing. + long occurrenceTime = !f.containsKey(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME) + && com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.read(totals) == null + ? wireVote.time : wireVote.canonicalOccurrenceTime; + triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid, + wireVote.voteId, origin, wireVote.delayValidated, wireVote.delayValidationKnown, + occurrenceTime); } } return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java index b898d8f786..94e938e184 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestones.java @@ -22,6 +22,7 @@ private record Definition(DateVoteEvent event, YamlConfiguration rewards) { } private final VotingPluginMain plugin; private volatile List definitions = List.of(); private final java.util.Set pendingProgress = new java.util.HashSet<>(); + private final java.util.concurrent.atomic.AtomicInteger deferredAccounting = new java.util.concurrent.atomic.AtomicInteger(); private DateVoteLedger ledger; public DateVoteMilestones(VotingPluginMain plugin) { this.plugin = plugin; } private synchronized DateVoteLedger ledger() { @@ -118,6 +119,35 @@ static String path(DateVoteEvent event, int threshold) { private static String configPath(DateVoteEvent event, int threshold) { return "DateVoteMilestones." + event.id() + ".Milestones." + threshold + ".Rewards"; } + /** Owner-thread extension events use the existing ordered storage worker without waiting on gameplay. */ + public boolean deferAccepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, + boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) { + if (plugin.getBungeeSettings().isUseBungeecoord() && !proxy) return true; + if (!real || occurrence == null || occurredAt <= 0 || proxy && (!canonicalProxy || targeted)) return true; + List snapshot = definitions; + if (snapshot.stream().noneMatch(definition -> definition.event().matches(occurredAt, site, real, proxy, + plugin.getBungeeSettings().getServer()))) return true; + int pending; + do { + pending = deferredAccounting.get(); + if (pending >= 64) { + plugin.getLogger().warning("DateVoteMilestones deferred accounting is busy; occurrence remains unconfirmed."); + return false; + } + } while (!deferredAccounting.compareAndSet(pending, pending + 1)); + try { + plugin.getUserManager().getDataManager().getTimer().execute(() -> { + try { + if (!plugin.isEnabled() || snapshot != definitions) return; + accepted(user, site, occurrence, occurredAt, real, proxy, canonicalProxy, targeted, forceProxyRouting); + } finally { deferredAccounting.decrementAndGet(); } + }); + } catch (RejectedExecutionException rejected) { + deferredAccounting.decrementAndGet(); + plugin.getLogger().warning("DateVoteMilestones deferred accounting was rejected; occurrence remains unconfirmed."); + } + return false; + } /** Called only from the real asynchronous accepted-vote pipeline; never from navigation. */ public boolean accepted(VotingPluginUser user, String site, UUID occurrence, long occurredAt, boolean real, boolean proxy, boolean canonicalProxy, boolean targeted, boolean forceProxyRouting) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java index 5373b7a04c..154302129f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java @@ -47,6 +47,19 @@ public class VoteTimeQueue { @Getter @Setter private String totals; + + public long getCanonicalOccurrenceTime() { + Long original = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.read(totals); + return original == null ? 0L : original; + } + + public void setCanonicalOccurrenceTime(long time) { + totals = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.store(totals, time); + } + + public void setTotals(String updated) { + totals = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.preserve(totals, updated); + } @Getter @Setter private boolean processed; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index dbf3856330..5eb5433ce2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1000,6 +1000,14 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId, boolean targetedProxyVote, boolean realVote) { + bungeeVotePluginMessaging(service, time, text, setTotals, wasOnline, broadcast, num, queuedProxyVote, + proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, realVote, time); + } + + public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, + boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, + boolean proxyDelayValidationKnown, boolean proxyQueueClassificationKnown, UUID proxyVoteId, + boolean targetedProxyVote, boolean realVote, long canonicalOccurrenceTime) { if (plugin.getBungeeSettings().isUseBungeecoord()) { plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); @@ -1009,6 +1017,7 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps voteEvent.setVotingPluginUser(this); voteEvent.setForceBungee(true); voteEvent.setTime(time); + voteEvent.setCanonicalOccurrenceTime(canonicalOccurrenceTime); voteEvent.setAddTotals(setTotals); voteEvent.setBungeeTextTotals(text); voteEvent.setProxyVoteId(proxyVoteId); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java index e7042a0d62..2ef0e4c432 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -18,6 +18,19 @@ public final class BukkitCompletionScheduler { public static boolean isSuccessfulEntityTaskResult(Object status) { return status instanceof Enum result && "SUCCESS".equals(result.name()); } + + /** Event flags are not a reliable execution-context check, including Folia region threads. */ + public static boolean isPlatformOwnedThread() { + try { if (org.bukkit.Bukkit.isPrimaryThread()) return true; } catch (RuntimeException ignored) { } + Object server = org.bukkit.Bukkit.getServer(); + if (server != null) try { + if (Boolean.TRUE.equals(server.getClass().getMethod("isGlobalTickThread").invoke(server))) return true; + } catch (ReflectiveOperationException | RuntimeException ignored) { } + for (String name : new String[] { "ca.spottedleaf.moonrise.common.util.TickThread", "io.papermc.paper.util.TickThread" }) try { + if (Boolean.TRUE.equals(Class.forName(name).getMethod("isTickThread").invoke(null))) return true; + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { } + return false; + } private BukkitCompletionScheduler() { } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java index 3db8173326..50bd4203d3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java @@ -97,7 +97,7 @@ void setUp() throws Exception { doCallRealMethod().when(user).addPoints(anyInt()); doCallRealMethod().when(user).addPoints(anyInt(), anyBoolean()); doCallRealMethod().when(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); var dispatcher = plugin.getServer().getPluginManager(); var listener = new PlayerVoteListener(plugin); @@ -155,6 +155,189 @@ private JsonEnvelope vote(UUID id, boolean queued) { builder.put(VotingPluginWire.K_QUEUED_DELIVERY, queued).build()); } + @Test + void reliableForwardingSeparatesOriginalMembershipFromReceiverCooldownTime() throws Exception { + config.set("DateVoteMilestones.october.Start", "2026-10-15T00:00:00"); + config.set("DateVoteMilestones.october.End", "2026-10-16T00:00:00"); + reloadDefinitions(); + UUID id = UUID.randomUUID(); + JsonEnvelope forwarded = receiverEnvelope(id, OCCURRED_AT, null, true); + var wire = VotingPluginWire.readVote(forwarded); + assertEquals(OCCURRED_AT, wire.canonicalOccurrenceTime); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, progress("october").votes()); + assertEquals(java.util.Set.of(1), progress("october").submittedAwards()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, accepted.get(0).getCanonicalOccurrenceTime()); + assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime()); + assertEquals(wire.time, posted.get(0).getVoteTime()); + assertNormalEffectsOnce(id, wire.time); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } + + @Test + void newForwarderPreservesIndependentOccurrenceTimeAcrossAnotherHop() throws Exception { + UUID id = UUID.randomUUID(); + var forwarded = receiverEnvelope(id, 1L, OCCURRED_AT, true); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, progress("october").votes()); + assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, VotingPluginWire.readVote(forwarded).time); + } + + @Test + void earlierOccurrenceAndOriginlessForwardingRemainOutsideCanonicalAccounting() throws Exception { + for (boolean originKnown : new boolean[] { true, false }) { + UUID id = UUID.randomUUID(); + var forwarded = receiverEnvelope(id, 1L, null, originKnown); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + } + assertEquals(0, progress("october").votes()); + assertEquals(2, posted.size()); + assertEquals(1L, posted.get(0).getCanonicalOccurrenceTime()); + assertEquals(-1L, posted.get(1).getCanonicalOccurrenceTime()); + verify(user, times(2)).playerVote(site, true, true); + verify(user, times(2)).addTotal(); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void malformedOccurrenceMetadataExcludesDateMembershipWithoutChangingNormalProcessing() throws Exception { + UUID id = UUID.randomUUID(); + var original = vote(id, false); + var forwarded = original.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, + "9".repeat(100)).build(); + var outcome = new AtomicReference(); + router.handleOrderedVote(forwarded, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(0, progress("october").votes()); + assertEquals(-1L, accepted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, OCCURRED_AT); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void oldBackendEnvelopeWithoutOccurrenceProvenanceKeepsNormalEffectsOnly() throws Exception { + UUID id = UUID.randomUUID(); + var source = vote(id, false); + var oldSender = JsonEnvelope.builder(source.getSubChannel()).schema(source.getSchema()); + source.getFields().forEach((key, value) -> { + if (!key.equals(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)) oldSender.put(key, value); + }); + var outcome = new AtomicReference(); + router.handleOrderedVote(oldSender.build(), outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(0, progress("october").votes()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(-1L, posted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + @Test + void cachedReplayPreservesOccurrenceAndEstablishedProcessingTime() throws Exception { + UUID id = UUID.randomUUID(); + var cached = new com.bencodez.votingplugin.proxy.OfflineBungeeVote(id, "Player", PLAYER.toString(), + SERVICE, 1L, true, ""); + cached.setCanonicalOccurrenceTime(OCCURRED_AT); + cached.setWasOnline(true); + var envelope = cachedEnvelope(cached); + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(1, progress("october").votes()); + assertEquals(OCCURRED_AT, posted.get(0).getCanonicalOccurrenceTime()); + assertEquals(1L, posted.get(0).getVoteTime()); + assertNormalEffectsOnce(id, 1L); + } + + @Test + void legacyCacheReplayDoesNotInventOccurrenceFromProcessingTime() throws Exception { + UUID id = UUID.randomUUID(); + var cached = new com.bencodez.votingplugin.proxy.OfflineBungeeVote(id, "Player", PLAYER.toString(), + SERVICE, OCCURRED_AT, true, ""); + cached.setWasOnline(true); + var outcome = new AtomicReference(); + router.handleOrderedVote(cachedEnvelope(cached), outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + assertEquals(0, progress("october").votes()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(-1L, posted.get(0).getCanonicalOccurrenceTime()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + private JsonEnvelope cachedEnvelope(com.bencodez.votingplugin.proxy.OfflineBungeeVote cached) throws Exception { + var proxy = new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl(); + var method = com.bencodez.votingplugin.proxy.VotingPluginProxy.class.getDeclaredMethod("cachedVoteEnvelope", + com.bencodez.votingplugin.proxy.OfflineBungeeVote.class, boolean.class, boolean.class, + int.class, int.class); + method.setAccessible(true); + return (JsonEnvelope) method.invoke(proxy, cached, false, false, 1, 1); + } + + private JsonEnvelope receiverEnvelope(UUID id, long senderTime, Long originalTime, boolean originKnown) throws Exception { + var proxy = spy(new com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl()); + proxy.setDataFolder(root.resolve(id.toString()).toFile()); + var votes = mock(com.bencodez.votingplugin.proxy.cache.VoteCacheHandler.class); + when(votes.markMultiProxyVoteCompletedDurably(any())).thenReturn(true); + when(votes.getTimeChangeQueue()).thenReturn(new java.util.concurrent.ConcurrentLinkedQueue<>()); + when(votes.addOnlineVoteDurably(anyString(), any())).thenReturn(true); + when(votes.addServerVoteDurably(anyString(), any())).thenReturn(true); + when(votes.updateOnlineVote(anyString(), any())).thenReturn(true); + when(votes.updateServerVote(anyString(), any())).thenReturn(true); + doReturn(votes).when(proxy).getVoteCacheHandler(); + doNothing().when(proxy).addVoteParty(); + proxy.setMethod(com.bencodez.votingplugin.proxy.BungeeMethod.PLUGINMESSAGING); + proxy.setGlobalMessageProxyHandlerForTest(new com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler() { + @Override public void sendMessage(String server, int delay, JsonEnvelope envelope) { } + }); + proxy.loadMultiProxySupport(); + var handler = spy(proxy.getMultiProxyHandler()); + doNothing().when(handler).acknowledgeMultiProxyVote(any(), anyString()); + proxy.setMultiProxyHandler(handler); + when(proxy.getConfig().getMultiProxySupport()).thenReturn(true); + when(proxy.getConfig().getOnlineMode()).thenReturn(true); + when(proxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + var legacyField = com.bencodez.votingplugin.proxy.VotingPluginProxy.class.getDeclaredField("legacyVoteDeliveryServers"); + legacyField.setAccessible(true); + @SuppressWarnings("unchecked") var legacy = (java.util.Set) legacyField.get(proxy); + legacy.add("server1"); legacy.add("server2"); + var input = originKnown + ? VotingPluginWire.multiProxyVote("Player", PLAYER.toString(), SERVICE, senderTime, + true, true, "", id, false, false, 1, 1, "Primary", true) + : VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, senderTime, + true, true, "", id, false, false, 1, 1); + if (originalTime != null) input = input.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, originalTime).build(); + else { + // Exercise the actual pre-metadata sender shape, including reliable origin. + var legacyBuilder = JsonEnvelope.builder(input.getSubChannel()).schema(input.getSchema()); + input.getFields().forEach((key, value) -> { + if (!key.equals(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME)) legacyBuilder.put(key, value); + }); + input = legacyBuilder.build(); + } + var handle = com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handle.setAccessible(true); + long before = System.currentTimeMillis(); + handle.invoke(handler, input); + long after = System.currentTimeMillis(); + var emitted = proxy.getLastVoteEnvelope(); + assertNotNull(emitted); + var wire = VotingPluginWire.readVote(emitted); + assertTrue(wire.time >= before && wire.time <= after); + assertEquals(PLAYER.toString(), wire.uuid); + if (originKnown) { + assertEquals(id, wire.voteId); + assertTrue(wire.delayValidated); assertTrue(wire.delayValidationKnown); + } + return emitted; + } + @Test void liveAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { assertAccountingFailureFinishesNormalEffects(false); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java index 1ebe31f1e8..6b41e7a929 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendProxyMessageRouterTest.java @@ -200,9 +200,9 @@ void actualAccountingDispatchFailureQuarantinesOriginalIdAndNeverReplaysNormalPr org.mockito.Mockito.doAnswer(call -> { dispatchUser.bungeeVotePluginMessaging(call.getArgument(0), call.getArgument(1), call.getArgument(2), call.getArgument(3), call.getArgument(4), call.getArgument(5), call.getArgument(6), call.getArgument(7), - call.getArgument(8), call.getArgument(9), call.getArgument(10), call.getArgument(11), call.getArgument(12)); return null; + call.getArgument(8), call.getArgument(9), call.getArgument(10), call.getArgument(11), call.getArgument(12), call.getArgument(13)); return null; }).when(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), - anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); + anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); var router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); var envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1)); AtomicReference outcome = new AtomicReference<>(); @@ -223,7 +223,7 @@ void voteRewardFailureRequestsQuarantineAfterVoteIdReservation() { .thenReturn(user); doThrow(new IllegalStateException("partial reward")).when(user).bungeeVotePluginMessaging( any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), - anyBoolean(), any(), anyBoolean(), anyBoolean()); + anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); BackendProxyMessageRouter voteRouter = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); @@ -269,7 +269,7 @@ void reliableVoteRetriesReceiptPersistenceWithoutRepeatingEffectsInProcess() { assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); verify(cache, times(2)).complete(voteId); verify(user, times(1)).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false), anyBoolean()); + anyBoolean(), anyInt(), eq(false), eq(true), eq(true), eq(voteId), eq(false), anyBoolean(), anyLong()); } @Test @@ -290,7 +290,7 @@ void stableVoteIdAloneDoesNotBypassBackendDelayValidation() { LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false), anyBoolean()); + anyBoolean(), anyInt(), eq(false), eq(false), eq(true), eq(voteId), eq(false), anyBoolean(), anyLong()); } @Test @@ -307,7 +307,7 @@ void canonicalTestVoteFlagIsPassedToTheBackendEventAdapter() { voteRouter.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER_UUID.toString(), "known.example", LAST_VOTE_TIME, true, false, "", voteId, false, false, 1, 1), ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(false), eq(false)); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(false), eq(false), anyLong()); } @Test @@ -327,7 +327,7 @@ void voteOnlineMarksTheDeliveryAsTargetedToThisBackend() { LAST_VOTE_TIME, true, true, "", voteId, false, false, 1, 1), ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true), anyBoolean()); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), eq(voteId), eq(true), anyBoolean(), anyLong()); } @Test @@ -349,7 +349,7 @@ void malformedReliableVoteIsQuarantinedWithoutCompletionReceipt() { assertEquals(OrderedVoteOutcome.QUARANTINE, outcome.get()); verify(cache, never()).complete(voteId); verify(user, never()).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); verify(cache).cancelReservation(voteId); } @@ -369,7 +369,7 @@ void saturatedReliableVoteRequestsRetryWithoutRunningEffects() { assertEquals(OrderedVoteOutcome.RETRY, outcome.get()); verify(user, never()).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean()); + anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); verify(cache, never()).complete(voteId); } @@ -501,7 +501,7 @@ void explicitQueuedDeliveryIsPassedSeparatelyFromDelayValidation() { voteRouter.handleOrderedVote(queued, ignored -> { }); verify(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), - anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true), anyBoolean()); + anyBoolean(), anyInt(), eq(true), eq(true), eq(true), eq(voteId), eq(true), anyBoolean(), anyLong()); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java index 824989426d..056cdb69e2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/listeners/PlayerVoteListenerProxyVoteIdTest.java @@ -31,6 +31,27 @@ void explicitTransportIdentityWinsOverLegacySnapshot() { assertEquals(explicitId, PlayerVoteListener.resolveProxyVoteId(event)); } + @Test void asynchronousEventFlagOnActualOwnerThreadDefersRatherThanTouchingLedger() throws Exception { + try (var bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::isPrimaryThread).thenReturn(true); + var event = new PlayerVoteEvent(null, "Ben", "Example", true); + event.setTime(123L); event.setBungee(true); UUID id = UUID.randomUUID(); event.setProxyVoteId(id); + org.junit.jupiter.api.Assertions.assertTrue(event.isAsynchronous()); + var plugin = org.mockito.Mockito.mock(com.bencodez.votingplugin.VotingPluginMain.class); + var manager = org.mockito.Mockito.mock(com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones.class); + org.mockito.Mockito.when(plugin.getDateVoteMilestones()).thenReturn(manager); + var user = org.mockito.Mockito.mock(com.bencodez.votingplugin.user.VotingPluginUser.class); + var site = org.mockito.Mockito.mock(com.bencodez.votingplugin.votesites.VoteSite.class); + org.mockito.Mockito.when(site.getKey()).thenReturn("Example"); + Class operations = Class.forName(PlayerVoteListener.class.getName()+"$BukkitOperations"); + var constructor = operations.getDeclaredConstructor(com.bencodez.votingplugin.VotingPluginMain.class, PlayerVoteEvent.class); constructor.setAccessible(true); + var method = operations.getDeclaredMethod("dateMilestones",com.bencodez.votingplugin.user.VotingPluginUser.class,com.bencodez.votingplugin.votesites.VoteSite.class,long.class,UUID.class);method.setAccessible(true); + method.invoke(constructor.newInstance(plugin,event),user,site,123L,id); + org.mockito.Mockito.verify(manager).deferAccepted(user,"Example",id,123L,true,true,true,false,false); + org.mockito.Mockito.verify(manager, org.mockito.Mockito.never()).accepted(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.anyLong(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean(),org.mockito.ArgumentMatchers.anyBoolean()); + org.junit.jupiter.api.Assertions.assertTrue(event.isDateMilestoneAccountingFailed()); + } + } @Test void dateAccountingRequiresExplicitTransportIdentityInsteadOfLegacyCorrelationFallback() throws Exception { for (boolean explicit : new boolean[] {false, true}) { UUID voteId = UUID.randomUUID(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java index 42a6aca723..081c21bfbe 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyHandlerLifecycleTest.java @@ -477,7 +477,7 @@ void unsignedBridgeCopiesDeduplicateLegacyVoteIdsButNotOriginBoundOrSignedTraffi handler.acceptRedisEnvelope(reliableVote, legacy); verify(handler, org.mockito.Mockito.times(2)).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(reliableVoteId), org.mockito.ArgumentMatchers.eq("Proxy1")); @@ -567,7 +567,7 @@ void authenticatedMultiProxyVoteIsAcceptedOnlyOnce(@TempDir Path dataDirectory) verify(handler, org.mockito.Mockito.times(1)).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(1L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Replica")); @@ -668,7 +668,7 @@ void forwardsOriginAndStableWireVoteIdToDurableTrigger() throws Exception { verify(handler).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(100L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Primary")); @@ -687,7 +687,7 @@ void forwardsPrimaryDelayValidationToDurableTrigger() throws Exception { verify(handler).triggerVote(org.mockito.ArgumentMatchers.eq("Player"), org.mockito.ArgumentMatchers.eq("Service"), org.mockito.ArgumentMatchers.eq(true), - org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(100L), + org.mockito.ArgumentMatchers.eq(true), org.mockito.ArgumentMatchers.eq(0L), org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Primary"), diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java index 435496f8da..d52741d356 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -315,4 +315,50 @@ private long countPlayerRecords(Path directory) { } catch (java.io.IOException failure) { throw new AssertionError(failure); } } + @Test void ownerEventAccountingIsBoundedDeferredAndPreservesCanonicalDeduplication() throws Exception { + event("october", ""); var m = manager(); when(plugin.isEnabled()).thenReturn(true); + var queue = new java.util.ArrayDeque(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); + doAnswer(call -> { queue.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + UUID id = UUID.randomUUID(); + for (int i = 0; i < 65; i++) assertFalse(m.deferAccepted(user, "a", id, time, true, false, false, false, false)); + assertEquals(64, queue.size()); assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + while (!queue.isEmpty()) queue.remove().run(); + var definition = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + assertEquals(1, new DateVoteLedger(root.resolve("date-vote-milestones")).progress(definition, player).votes()); + verify(plugin.getRewardHandler(), times(1)).giveReward(eq(user), any(), anyString(), any()); + assertFalse(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertEquals(1, queue.size()); + } + @Test void deferredOwnerAccountingDoesNotCrossReloadOrDisableAndRejectsWithoutDisk() { + for (boolean reload : new boolean[] { false, true }) { + event("october", ""); var m = manager(); when(plugin.isEnabled()).thenReturn(true); + var queue = new java.util.ArrayDeque(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getUserManager().getDataManager().getTimer()).thenReturn(timer); + doAnswer(call -> { queue.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + assertFalse(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + if (reload) m.reload(); else when(plugin.isEnabled()).thenReturn(false); + queue.remove().run(); assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + doThrow(new java.util.concurrent.RejectedExecutionException("fixture retired")).when(timer).execute(any(Runnable.class)); + assertFalse(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + } + } + @Test void disabledUnmatchedFakeAndUnidentifiedProxyEventsNeedNoOwnerHandoff() { + event("october", ""); var m = manager(); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, false, false, false, false, false)); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, true, false, false, false)); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), 1L, true, false, false, false, false)); + config.set("DateVoteMilestones.october.Enabled", false); m.reload(); + assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any(Runnable.class)); + } + @Test void absentDateDefinitionsNeedNoOwnerThreadHandoffOrQuarantine() { + var m = manager(); assertTrue(m.deferAccepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + verify(plugin.getUserManager().getDataManager().getTimer(), never()).execute(any(Runnable.class)); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java index 9698ac0bec..9e94245d87 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java @@ -47,6 +47,7 @@ import com.bencodez.votingplugin.proxy.cache.ProxyTimedVoteCacheTable; import com.bencodez.votingplugin.proxy.cache.ProxyVoteCacheTable; import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; +import com.bencodez.votingplugin.proxy.velocity.VelocityJsonVoteCache; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; /** @@ -1387,6 +1388,69 @@ private static DataNode timedVoteNode(VoteTimeQueue vote) { return new GsonDataNode(data); } + @Test + void occurrenceMetadataSurvivesBothJsonAdaptersAndCacheRestart() throws Exception { + for (boolean bungee : new boolean[] { true, false }) { + Path folder = tempDir.resolve(bungee ? "bungee" : "velocity"); + Files.createDirectories(folder); + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(folder.toFile()); + IVoteCache store = bungee ? new BungeeJsonVoteCache(plugin) + : new VelocityJsonVoteCache(folder.resolve("votecache.json").toFile()); + VoteCacheHandler writer = newHandler(store); + OfflineBungeeVote server = vote(UUID.randomUUID(), 456L); + server.setCanonicalOccurrenceTime(123L); + OfflineBungeeVote online = vote(UUID.randomUUID(), 457L); + online.setCanonicalOccurrenceTime(123L); + VoteTimeQueue timed = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 458L); + timed.setUuid("player-uuid"); timed.setCanonicalOccurrenceTime(123L); + timed.setMultiProxyOrigin("Primary"); + assertTrue(writer.addServerVoteDurably("server", server)); + assertTrue(writer.addOnlineVoteDurably("player-uuid", online)); + assertTrue(writer.addTimeVoteToCache(timed)); + IVoteCache recoveredStore = bungee ? new BungeeJsonVoteCache(plugin) + : new VelocityJsonVoteCache(folder.resolve("votecache.json").toFile()); + VoteCacheHandler restarted = newHandler(recoveredStore); + restarted.load(); + assertEquals(456L, restarted.getVotes("server").get(0).getTime()); + assertEquals(123L, restarted.getVotes("server").get(0).getCanonicalOccurrenceTime()); + assertEquals(457L, restarted.getOnlineVotes("player-uuid").get(0).getTime()); + assertEquals(123L, restarted.getOnlineVotes("player-uuid").get(0).getCanonicalOccurrenceTime()); + assertEquals(458L, restarted.getTimeChangeQueue().element().getTime()); + assertEquals(123L, restarted.getTimeChangeQueue().element().getCanonicalOccurrenceTime()); + assertEquals("Primary", restarted.getTimeChangeQueue().element().getMultiProxyOrigin()); + } + } + + @Test + void sqlRecoveryDecodesOccurrenceFromAllExistingOpaquePayloadColumns() throws Exception { + VoteCacheHandler recovered = newHandler(storage); + ProxyVoteCacheTable serverTable = mock(ProxyVoteCacheTable.class); + ProxyOnlineVoteCacheTable onlineTable = mock(ProxyOnlineVoteCacheTable.class); + ProxyTimedVoteCacheTable timedTable = mock(ProxyTimedVoteCacheTable.class); + String payload = com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata.store("v2//1//2//3//4//5//6//7//8", 123L); + UUID serverId = UUID.randomUUID(), onlineId = UUID.randomUUID(), timedId = UUID.randomUUID(); + when(serverTable.getAllVotes()).thenReturn(List.of(new ProxyVoteCacheTable.VoteRow(1, + serverId.toString(), "player-uuid", "Player", "Service", 456L, true, false, false, + payload, false, false, "", "", false, "", "", "server"))); + when(onlineTable.getAllVotes()).thenReturn(List.of(new ProxyOnlineVoteCacheTable.VoteRow(2, + onlineId.toString(), "player-uuid", "Player", "Service", 457L, true, false, false, + payload, false, false, "", "", false, "", ""))); + when(timedTable.getAllVotes()).thenReturn(List.of(new ProxyTimedVoteCacheTable.TimedVoteRow(3, + "Player", "Service", 458L, timedId, "player-uuid", false, "", "", payload, false))); + setPrivateField(recovered, "useMySQL", true); + setPrivateField(recovered, "voteCacheTable", serverTable); + setPrivateField(recovered, "onlineVoteCacheTable", onlineTable); + setPrivateField(recovered, "timedVoteCacheTable", timedTable); + recovered.load(); + assertEquals(456L, recovered.getVotes("server").get(0).getTime()); + assertEquals(123L, recovered.getVotes("server").get(0).getCanonicalOccurrenceTime()); + assertEquals(457L, recovered.getOnlineVotes("player-uuid").get(0).getTime()); + assertEquals(123L, recovered.getOnlineVotes("player-uuid").get(0).getCanonicalOccurrenceTime()); + assertEquals(458L, recovered.getTimeChangeQueue().element().getTime()); + assertEquals(123L, recovered.getTimeChangeQueue().element().getCanonicalOccurrenceTime()); + } + private static VoteCacheHandler newHandler(IVoteCache storage) { return newHandler(storage, true); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java new file mode 100644 index 0000000000..a30857e9a8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteOccurrenceMetadataTest.java @@ -0,0 +1,63 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.VoteOccurrenceMetadata; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; + +class VoteOccurrenceMetadataTest { + @SuppressWarnings("deprecation") + @Test void metadataPreservesLegacyAndVersionedTotalsAndCorrelation() { + UUID correlation = UUID.randomUUID(); + for (String payload : new String[] { + "1//2//3//4//5//999//6//7//8//" + correlation, + "v2//1//2//3//4//5//6//7//8//" + correlation }) { + String decorated = VoteOccurrenceMetadata.store(payload, 123L); + var original = VoteTotalsSnapshot.parseStorage(payload); + var restored = VoteTotalsSnapshot.parseStorage(decorated); + assertEquals(original.toStorageString(), restored.toStorageString()); + assertEquals(correlation, restored.getVoteUUID()); + assertEquals(123L, VoteOccurrenceMetadata.read(decorated)); + } + } + + @Test void cachedTotalsUpdatesAndCopiesKeepOccurrenceSeparateFromCooldownTime() { + var offline = new OfflineBungeeVote(UUID.randomUUID(), "Player", "uuid", "Service", 456L, true, ""); + offline.setCanonicalOccurrenceTime(123L); + offline.setText("v2//1//2//3//4//5//6//7//8"); + var copied = new OfflineBungeeVote(offline.getVoteId(), offline.getPlayerName(), offline.getUuid(), + offline.getService(), offline.getTime(), offline.isRealVote(), offline.getText()); + assertEquals(456L, copied.getTime()); + assertEquals(123L, copied.getCanonicalOccurrenceTime()); + var timed = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 789L); + timed.setCanonicalOccurrenceTime(123L); + timed.setTotals("v2//8//7//6//5//4//3//2//1"); + assertEquals(789L, timed.getTime()); + assertEquals(123L, timed.getCanonicalOccurrenceTime()); + assertEquals(8, VoteTotalsSnapshot.parseStorage(timed.getTotals()).getAllTimeTotal()); + } + + @Test void malformedOversizedAndConflictingMetadataCannotBecomeDateProvenance() { + for (String token : new String[] { "", "0", "-1", "bad", "9".repeat(100), "123//vp-occurrence-time:456" }) { + String payload = "v2//1//2//3//4//5//6//7//8//vp-occurrence-time:" + token; + assertEquals(-1L, VoteOccurrenceMetadata.read(payload)); + var envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 789L, + true, true, payload, UUID.randomUUID(), false, false, 1, 1); + assertEquals(-1L, VotingPluginWire.readVote(envelope).canonicalOccurrenceTime); + assertEquals(789L, VotingPluginWire.readVote(envelope).time); + assertEquals(1, VoteTotalsSnapshot.parseStorage(payload).getAllTimeTotal()); + } + var envelope = VotingPluginWire.vote("Player", UUID.randomUUID().toString(), "Service", 789L, + true, true, VoteOccurrenceMetadata.store("", 123L), UUID.randomUUID(), false, false, 1, 1); + var conflicting = envelope.toBuilder().put(VotingPluginWire.K_CANONICAL_OCCURRENCE_TIME, 456L).build(); + assertEquals(-1L, VotingPluginWire.readVote(conflicting).canonicalOccurrenceTime); + assertEquals(789L, VotingPluginWire.readVote(conflicting).time); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java index 6d2ac3fc4b..e094f3c413 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -720,6 +720,9 @@ public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { target.setAccessible(true); target.set(retry, field.getValue()); } + java.lang.reflect.Field occurrence = retryType.getDeclaredField("canonicalOccurrenceTime"); + occurrence.setAccessible(true); + occurrence.setLong(retry, 123L); java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); retriesField.setAccessible(true); @SuppressWarnings("unchecked") @@ -738,7 +741,11 @@ public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { @SuppressWarnings({ "rawtypes", "unchecked" }) org.mockito.ArgumentCaptor> recipients = (org.mockito.ArgumentCaptor) org.mockito.ArgumentCaptor.forClass(java.util.Collection.class); - verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(Mockito.any(), recipients.capture()); + org.mockito.ArgumentCaptor envelopes = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(multiProxyHandler, Mockito.times(2)).sendMultiProxyEnvelopeAccepted(envelopes.capture(), recipients.capture()); + assertTrue(envelopes.getAllValues().stream().allMatch(envelope -> + VotingPluginWire.readVote(envelope).canonicalOccurrenceTime == 123L + && VotingPluginWire.readVote(envelope).time == 100L)); assertEquals(java.util.Set.of(java.util.Set.of("proxy2"), java.util.Set.of("ProxyLegacy")), new java.util.HashSet<>(recipients.getAllValues())); java.lang.reflect.Method settle = VotingPluginProxy.class @@ -750,6 +757,8 @@ public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { verify(voteCache, Mockito.atLeast(2)).addTimeVoteToCache(outboxes.capture()); assertTrue(outboxes.getAllValues().stream().allMatch(outbox -> voteId.equals(outbox.getVoteId()))); assertEquals(1, queue.size()); + assertEquals(100L, queue.element().getTime()); + assertEquals(123L, queue.element().getCanonicalOccurrenceTime()); assertEquals(voteId, queue.element().getVoteId()); assertEquals(java.util.Set.of("proxy2"), queue.element().getMultiProxyRecipients()); } @@ -840,6 +849,8 @@ void capabilityDiscoveryDefersVoteUntilExpiryThenSendsOneLegacyCopy() throws Exc verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); assertEquals(1, queue.size()); assertTrue(queue.element().isMultiProxyCapabilityDiscoveryPending()); + assertEquals(100L, queue.element().getTime()); + assertEquals(100L, queue.element().getCanonicalOccurrenceTime()); assertTrue(queue.element().isProcessed()); verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L), Mockito.eq(java.util.concurrent.TimeUnit.SECONDS)); @@ -1714,7 +1725,8 @@ void overflowForwardedVoteIsSpilledToDurableReplayQueueInsteadOfDropped() throws assertEquals(1, queue.size()); assertEquals(voteId, queue.element().getVoteId()); - assertEquals("", queue.element().getTotals()); + assertEquals(0, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.parseStorage(queue.element().getTotals()).getAllTimeTotal()); + assertEquals(100L, queue.element().getCanonicalOccurrenceTime()); assertFalse(queue.element().isRealVote()); assertEquals(1024, retryMap.size()); } diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 94f47ae959..fab3369d3f 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -36,7 +36,7 @@ Reload after configuration. `/vote dateevents` displays the player's event count Start is inclusive; end is exclusive. Values are local ISO date-times interpreted with the explicit IANA timezone, such as `UTC` or `America/New_York`. Nonexistent or ambiguous daylight-saving boundaries are rejected; choose an unambiguous boundary. Invalid events are disabled with an explanation without disabling valid sibling events. Stable event IDs permit 1–64 letters, digits, `_` and `-`. Up to 64 configured definitions and 64 positive thresholds per event are supported; thresholds are bounded to 4096. -Membership uses the accepted pipeline's authoritative epoch-millisecond occurrence time and occurrence UUID. Standalone ingress assigns its timestamp during accepted processing; an external site's untrusted timestamp is not used. Current identified proxy deliveries retain the original proxy occurrence time and UUID, including delayed offline delivery. A vote accepted during a window can count even when delivered after its end. Votes before start or at/after end do not count. Fake/test votes are always excluded. A matching occurrence counts once per player/event; overlapping events each count it once. Enabled-site validation remains part of the normal accepted-vote pipeline. An optional filter selects configured site keys. +Membership uses the accepted pipeline's authoritative epoch-millisecond occurrence time and occurrence UUID. Standalone ingress assigns its timestamp during accepted processing; an external site's untrusted timestamp is not used. Current identified proxy deliveries carry a separate `canonicalOccurrenceTime` and original UUID, including delayed offline delivery. Ordinary event processing time, LastVotes, cooldowns, streaks, and post-vote `voteTime` retain their existing receiving-proxy clock semantics. The native vote and post-vote events expose the separate occurrence timestamp. A vote accepted during a window can count even when delivered after its end. Votes before start or at/after end do not count. Fake/test votes are always excluded. A matching occurrence counts once per player/event; overlapping events each count it once. Enabled-site validation remains part of the normal accepted-vote pipeline. An optional filter selects configured site keys. For a proxy network, **one backend owns each event ledger**. Set `AccountingServer` to that backend's exact configured server name. Use identified **all-server** delivery so that accounting backend receives every occurrence. Other backends do not increment the event, and their progress command directs players to the owner. Targeted delivery, legacy proxy deliveries missing original IDs/times, and local backend ingress on a proxy-configured server are intentionally excluded. They cannot reliably provide both complete event progress and network deduplication. This does not change their existing normal vote processing. A proxy event without an accounting owner is disabled. Moving the owner requires preserving the owner-local ledger and a new event ID; this feature does not migrate or replicate ledgers automatically. @@ -64,8 +64,12 @@ Proxy transports open only after the existing milestone, streak, vote-party, spe If canonical proxy occurrence accounting cannot be durably confirmed, the accepted pipeline finishes its normal processing but marks the dispatch incomplete. The router finishes its remaining normal per-server points mutation and service-site observation before surfacing the date-accounting failure. The ordered proxy lane quarantines and retains the original envelope without acknowledging release or automatically replaying normal totals/rewards. Repair accounting storage and reconcile that original event/site/player/occurrence before manually releasing the quarantined delivery. Confirmed progress with a later reward-submission failure retains the existing DEFERRED/RESERVED award guarantees. Local ingress has no equivalent durable proxy envelope and logs unconfirmed progress for owner reconciliation. -Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. `PlayerVoteEvent.proxyVoteId` and `PlayerPostVoteEvent.proxyVoteId` preserve the original explicit wire ID (or remain null when absent). Legacy totals correlation remains the effective identity for normal milestones, streaks, post-vote `voteUUID`, and backend replay fencing. Reliable multi-proxy delivery retains the original wire timestamp through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older origin-less routes retain their legacy behavior and remain excluded from canonical date-event accounting. +Canonical date accounting requires an explicit transport vote ID; a legacy totals snapshot's correlation UUID is insufficient. `PlayerVoteEvent.proxyVoteId` and `PlayerPostVoteEvent.proxyVoteId` preserve the original explicit wire ID (or remain null when absent). Legacy totals correlation remains the effective identity for normal milestones, streaks, post-vote `voteUUID`, and backend replay fencing. Reliable multi-proxy delivery retains the original wire timestamp in the separate occurrence field through receiving-proxy forwarding, so delivery/retry time does not move a vote into a different event window. Older reliable senders with an explicit origin can supply their original wire time to an updated receiving proxy. Origin-less forwarding and backend envelopes without occurrence provenance remain excluded from canonical date accounting while normal rewards and cooldowns continue. An older intermediate proxy cannot recover occurrence metadata it did not retain; event accounting is unavailable for that route until its receiving proxy is updated. This requires no synchronized upgrade for ordinary voting. + +Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. Local Votifier and accepted-vote listeners are registered after date definitions and all normal reward handlers are ready, alongside the existing proxy-ingress readiness boundary. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. + +Extension events dispatched from a Bukkit/Paper/Folia gameplay thread are admitted to the existing ordered storage worker (at most 64 pending). The asynchronous event flag is not treated as a thread check. Deferred proxy accounting remains unconfirmed/quarantined until reconciliation; submission alone never acknowledges durable accounting. Reload, disable, or overload can withhold such extension occurrences. Native VotingPlugin asynchronous ingress retains synchronous completion reporting. From 87545cc41f1acf744dd68a3ea88eaaadb94f179d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 8 Oct 2026 20:47:22 -0600 Subject: [PATCH 12/22] Capture startup Votifier votes before reward pipeline readiness --- .../votingplugin/VotingPluginMain.java | 60 ++++-- .../votingplugin/data/ServerData.java | 2 + .../votingplugin/listeners/VotiferEvent.java | 51 +++-- .../listeners/VotifierVoteOverflowQueue.java | 26 ++- .../timequeue/TimeQueueHandler.java | 18 +- .../DateVoteIngressOrderingTest.java | 87 ++++---- .../DateVotifierStartupCaptureTest.java | 204 ++++++++++++++++++ .../VotiferEventDisabledVoteSiteTest.java | 4 +- .../TimeQueueHandlerRejectionTest.java | 44 ++++ docs/date-vote-milestones.md | 2 +- 10 files changed, 405 insertions(+), 93 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 5461a57517..16411dae1f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -321,7 +321,12 @@ private synchronized AtomicBoolean basicBungeeUpdateAdmission() { private VotingPluginConfigHealth configHealth; private VotifierIntegration votifierIntegration; @Getter - private VotifierVoteOverflowQueue votifierVoteOverflowQueue; + private volatile VotifierVoteOverflowQueue votifierVoteOverflowQueue; + private VotiferEvent localVotifierListener; + private boolean acceptedVoteIngressRegistered; + private boolean timeQueueIngressRegistered; + private volatile boolean localVotifierIngressReady; + private volatile boolean localVotifierIngressClosed; private VoteLogManager voteLogManager; private VotingPluginWebhookManager webhookManager; @@ -686,23 +691,34 @@ private void metrics() { /** Publish accepted and transport ingress only after the complete vote pipeline is initialized. */ void initializeDateVoteIngress(Runnable openProxy) { + if (localVotifierIngressClosed) throw new IllegalStateException("Vote ingress is shutting down"); + if (localVotifierIngressReady) return; if (voteMilestonesManager == null || voteStreakHandler == null || voteParty == null || specialRewards == null || topVoterHandler == null || voteShopManager == null || placeholders == null) { throw new IllegalStateException("Accepted-vote handlers must initialize before transport ingress"); } getDateVoteMilestones().reload(); - getServer().getPluginManager().registerEvents(new PlayerVoteListener(this), this); + if (!acceptedVoteIngressRegistered) { + getServer().getPluginManager().registerEvents(new PlayerVoteListener(this), this); + acceptedVoteIngressRegistered = true; + } // Construction itself schedules persisted replay. Open it only after its consumer. - if (!getBungeeSettings().isUseBungeecoord() || !getBungeeSettings().isGloblalDataEnabled()) { + if (!timeQueueIngressRegistered && (!getBungeeSettings().isUseBungeecoord() || !getBungeeSettings().isGloblalDataEnabled())) { timeQueueHandler = new TimeQueueHandler(this); getServer().getPluginManager().registerEvents(timeQueueHandler, this); + timeQueueIngressRegistered = true; } if (getBungeeSettings().isUseBungeecoord()) openProxy.run(); - registerLocalVotifierIngress(); + localVotifierIngressReady = true; + VotifierVoteOverflowQueue overflow = votifierVoteOverflowQueue; + if (overflow != null) overflow.start(); } @Override public void onPostLoad() { + loadVoteTimer(); + checkVotifier(); + registerEarlyVotifierIngress(); ensureCommunicationSecret(); // auto conversion for Shop.yml if (plugin.getShopFile().isJustCreated()) { @@ -730,7 +746,6 @@ public void onPostLoad() { voteTester = new VoteTester(plugin); - loadVoteTimer(); getVotingPluginUserManager().startSharedPointTransferRecovery(); // Proxy ingress opens below, after every accepted-vote handler is initialized. @@ -749,7 +764,6 @@ public void onPostLoad() { voteStreakHandler.reload(); registerCommands(); - checkVotifier(); registerEvents(); refreshPlaceholderPlayerPresence(); @@ -840,11 +854,6 @@ public Object getObject(OfflinePlayer player) { VotingPluginRewardRegistrar.register(this); initializeDateVoteIngress(this::loadBungeeHandler); - // Recovered Votifier votes may now traverse the fully initialized vote, - // reward, placeholder, shop, and vote-party pipeline. - if (votifierVoteOverflowQueue != null) { - votifierVoteOverflowQueue.start(); - } plugin.getLogger().info("Enabled VotingPlugin " + plugin.getDescription().getVersion()); if (plugin.getDescription().getVersion().contains("SNAPSHOT")) { @@ -1965,6 +1974,9 @@ private void shutdownVoteReminders() { @Override public void onUnLoad() { + localVotifierIngressClosed = true; + localVotifierIngressReady = false; + if (localVotifierListener != null) localVotifierListener.stop(); placeholderPlayerPresence.clear(); stopBackendHostedControlLifecycle(); stopBackendControlConnectorLifecycle(); @@ -2045,15 +2057,23 @@ private void registerCommands() { } - /** Local ingress is registered only after accepted-vote handlers and date definitions are ready. */ - private void registerLocalVotifierIngress() { - PluginManager pm = getServer().getPluginManager(); - if (isVotifierLoaded()) { - VotiferEvent votifierEvent = new VotiferEvent(this); - votifierVoteOverflowQueue = new VotifierVoteOverflowQueue(this, votifierEvent::processVote); - pm.registerEvents(votifierEvent, this); - } - } + /** Capture external events early; only the existing bounded workers may buffer/process them. */ + void registerEarlyVotifierIngress() { + if (localVotifierIngressClosed || !isVotifierLoaded() || localVotifierListener != null) return; + VotiferEvent listener = new VotiferEvent(this, () -> localVotifierIngressReady); + localVotifierListener = listener; + // Queue-file loading stays off the server owner. FIFO admission places this + // setup ahead of every captured vote, without a second startup collection. + getVoteTimer().submit(() -> { + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(this, + (VotifierVoteOverflowQueue.VoteProcessor) listener::processVote, + () -> localVotifierIngressReady && !localVotifierIngressClosed); + votifierVoteOverflowQueue = queue; + if (localVotifierIngressClosed) queue.close(); + else if (localVotifierIngressReady) queue.start(); + }); + getServer().getPluginManager().registerEvents(listener, this); + } private void registerEvents() { PluginManager pm = getServer().getPluginManager(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index a255b8d581..b287765823 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -90,6 +90,8 @@ public void addTimeVoted(int num, VoteTimeQueue vote) { getData().set("TimedVoteCache." + num + ".Name", vote.getName()); getData().set("TimedVoteCache." + num + ".Service", vote.getService()); getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); + getData().set("TimedVoteCache." + num + ".CanonicalOccurrenceTime", + vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime()); saveData(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 5e7188a5f9..3f12e0d832 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -17,16 +17,21 @@ public class VotiferEvent implements Listener { - private VotingPluginMain plugin; + private final VotingPluginMain plugin; + private final java.util.function.BooleanSupplier ready; + private volatile boolean accepting = true; /** * Instantiates a new votifer event. * * @param plugin the plugin */ - public VotiferEvent(VotingPluginMain plugin) { - this.plugin = plugin; - } + public VotiferEvent(VotingPluginMain plugin) { this(plugin, () -> true); } + + public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier ready) { + this.plugin = plugin; this.ready = ready; + } + public void stop() { accepting = false; } /** * Processes a validated vote. The overflow queue invokes this callback from @@ -35,7 +40,9 @@ public VotiferEvent(VotingPluginMain plugin) { * @param voteSite the validated service site * @param voteUsername the validated player name */ - public void processVote(String voteSite, String voteUsername) { + public void processVote(String voteSite, String voteUsername) { processVote(voteSite, voteUsername, 0L); } + + public void processVote(String voteSite, String voteUsername, long occurredAt) { try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -77,7 +84,7 @@ public void processVote(String voteSite, String voteUsername) { if (plugin.getTimeChecker().isActiveProcessing() && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); - plugin.getTimeQueueHandler().addVote(voteUsername, voteSite); + plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt); return; } @@ -85,6 +92,7 @@ public void processVote(String voteSite, String voteUsername) { PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true); + if (occurredAt > 0) voteEvent.setCanonicalOccurrenceTime(occurredAt); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isCancelled()) { @@ -103,6 +111,8 @@ public void processVote(String voteSite, String voteUsername) { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onVotiferEvent(VotifierEvent event) { + if (!accepting) return; + final long occurredAt = System.currentTimeMillis(); Vote vote = event.getVote(); String str = vote.getServiceName(); @@ -136,15 +146,22 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("IP: " + IP); try { - plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername)); - } catch (RejectedExecutionException rejected) { - VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) { - plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " - + MinecraftUsernameValidator.sanitizeForLog(voteUsername)); - } else { - plugin.getLogger().warning("Vote executor saturated; queued Votifier vote for retry"); - } - } - } + plugin.getVoteTimer().submit(() -> { + if (ready.getAsBoolean() && accepting) processVote(voteSite, voteUsername, occurredAt); + else bufferVote(voteUsername, voteSite, occurredAt); + }); + } catch (RejectedExecutionException rejected) { + bufferVote(voteUsername, voteSite, occurredAt); + } + } + + private void bufferVote(String username, String site, long occurredAt) { + VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); + if (overflow == null || !overflow.enqueue(username, site, occurredAt)) { + plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " + + MinecraftUsernameValidator.sanitizeForLog(username)); + } else { + plugin.getLogger().warning("Vote pipeline unavailable or saturated; queued Votifier vote for retry"); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 7ec1cb4c82..e32464aa19 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -42,7 +42,10 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { private static final String QUEUE_FILE = "VotifierVoteQueue.yml"; private final VotingPluginMain plugin; - private final BiConsumer processor; + @FunctionalInterface + public interface VoteProcessor { void process(String serviceSite, String username, long occurredAt); } + private final VoteProcessor processor; + private final java.util.function.BooleanSupplier processingAllowed; private final Path file; private final ScheduledThreadPoolExecutor worker; private final Object lock = new Object(); @@ -63,8 +66,15 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { * @param processor callback receiving service site and player name */ public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { + this(plugin, (site, user, occurredAt) -> processor.accept(site, user), () -> true); + } + + /** Additive callback retains receipt occurrence and fences a retired startup generation. */ + public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor, + java.util.function.BooleanSupplier processingAllowed) { this.plugin = plugin; this.processor = processor; + this.processingAllowed = processingAllowed; this.file = new File(plugin.getDataFolder(), QUEUE_FILE).toPath(); this.worker = new ScheduledThreadPoolExecutor(1, runnable -> { Thread thread = new Thread(runnable, "VotingPlugin-Votifier-Overflow"); @@ -97,10 +107,14 @@ public void start() { * @return false when the bounded overflow is full or shutting down */ public boolean enqueue(String username, String serviceSite) { - if (username == null || serviceSite == null) return false; + return enqueue(username, serviceSite, System.currentTimeMillis()); + } + + public boolean enqueue(String username, String serviceSite, long occurredAt) { + if (username == null || serviceSite == null || occurredAt <= 0) return false; synchronized (lock) { if (closed || entries.size() >= MAX_ENTRIES) return false; - entries.addLast(new PendingVote(username, serviceSite, System.currentTimeMillis())); + entries.addLast(new PendingVote(username, serviceSite, occurredAt)); stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -152,8 +166,12 @@ private void drain() { // Serialize admission with enqueue so the version proven durable // above cannot change in the gap before submit accepts this vote. plugin.getVoteTimer().submit(() -> { + if (!processingAllowed.getAsBoolean()) { + synchronized (lock) { pending.submitted = false; } + return; // Retired generation retains the durable entry for restart. + } try { - processor.accept(pending.serviceSite, pending.username); + processor.process(pending.serviceSite, pending.username, pending.time); } finally { acknowledge(pending); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index 3bf50342a8..f13965d3a5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -48,10 +48,14 @@ public TimeQueueHandler(VotingPluginMain plugin) { * @param voteUsername the voter username * @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())); - } + public void addVote(String voteUsername, String voteSiteName) { addVote(voteUsername, voteSiteName, 0L); } + + public void addVote(String voteUsername, String voteSiteName, long occurredAt) { + VoteTimeQueue queued = new VoteTimeQueue(voteUsername, voteSiteName, + LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + if (occurredAt > 0) queued.setCanonicalOccurrenceTime(occurredAt); + timeChangeQueue.add(queued); + } /** * Loads cached votes from server data and schedules queue processing. @@ -59,8 +63,9 @@ public void addVote(String voteUsername, String voteSiteName) { public void load() { for (String str : plugin.getServerData().getTimedVoteCacheKeys()) { ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str); - timeChangeQueue - .add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time"))); + VoteTimeQueue queued = new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time")); + if (data.contains("CanonicalOccurrenceTime")) queued.setCanonicalOccurrenceTime(data.getLong("CanonicalOccurrenceTime", -1L)); + timeChangeQueue.add(queued); } scheduleQueueProcessing(120, TimeUnit.SECONDS); } @@ -116,6 +121,7 @@ public void processQueue() { plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), vote.getService(), true); voteEvent.setTime(vote.getTime()); + if (vote.getCanonicalOccurrenceTime() != 0L) voteEvent.setCanonicalOccurrenceTime(vote.getCanonicalOccurrenceTime()); plugin.getServer().getPluginManager().callEvent(voteEvent); if (voteEvent.isCancelled()) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java index 4e32c60be7..37a947374a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -53,50 +53,37 @@ private static void ready(VotingPluginMain plugin) throws Exception { } } - @Test void localVotifierAndAcceptedListenersAreRegisteredOnlyAfterDefinitionsAndHandlers() throws Exception { + @Test void earlyCaptureIsRegisteredOnceAndConsumersOpenOnlyAfterDefinitionsAndHandlers() throws Exception { for (boolean proxyEnabled : new boolean[] {false, true}) { - VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); - YamlConfiguration config = new YamlConfiguration(); - String prefix = "DateVoteMilestones.local."; - config.set(prefix + "Enabled", true); config.set(prefix + "Timezone", "UTC"); - config.set(prefix + "Start", "2020-01-01T00:00:00"); config.set(prefix + "End", "2099-01-01T00:00:00"); - config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); - when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); - when(plugin.getDataFolder()).thenReturn(root.toFile()); - when(plugin.getOptions().isProcessRewards()).thenReturn(true); - when(plugin.isVotifierLoaded()).thenReturn(true); - DateVoteMilestones events = new DateVoteMilestones(plugin); - when(plugin.getDateVoteMilestones()).thenReturn(events); - VotingPluginUser user = mock(VotingPluginUser.class); when(user.getJavaUUID()).thenReturn(UUID.randomUUID()); - var listeners = new java.util.ArrayList(); - var pluginManager = plugin.getServer().getPluginManager(); - doAnswer(invocation -> { - verify(plugin).addDirectlyDefinedRewards(any()); - listeners.add(invocation.getArgument(0)); - if (invocation.getArgument(0) instanceof com.bencodez.votingplugin.listeners.VotiferEvent) - assertTrue(events.accepted(user, "a", UUID.randomUUID(), System.currentTimeMillis(), true, proxyEnabled, proxyEnabled, false, false)); - return null; - }).when(pluginManager).registerEvents(any(), eq(plugin)); - ready(plugin); doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); - when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(proxyEnabled); - when(plugin.getBungeeSettings().getServer()).thenReturn("backend"); - config.set(prefix + "AccountingServer", "backend"); - var opened = new java.util.concurrent.atomic.AtomicBoolean(); - Runnable proxy = () -> { - assertEquals(2, listeners.size()); - assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, listeners.getFirst()); - opened.set(true); - }; - try (var overflow = mockConstruction(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class)) { - plugin.initializeDateVoteIngress(proxy); - assertEquals(1, overflow.constructed().size()); - } - assertEquals(3, listeners.size()); - assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, listeners.get(0)); - assertInstanceOf(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, listeners.get(1)); - assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, listeners.get(2)); - verify(plugin.getRewardHandler()).giveReward(eq(user), any(), startsWith("DateVoteMilestonesRuntime."), any()); - assertEquals(proxyEnabled, opened.get()); + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.isVotifierLoaded()).thenReturn(true); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(proxyEnabled); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(plugin.getVoteTimer()).thenReturn(timer); + var registered = new java.util.ArrayList(); + var manager = plugin.getServer().getPluginManager(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin)); + doCallRealMethod().when(plugin).registerEarlyVotifierIngress(); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + try (var queues = mockConstruction(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class)) { + plugin.registerEarlyVotifierIngress(); plugin.registerEarlyVotifierIngress(); + assertEquals(1, registered.size()); + assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, registered.getFirst()); + verify(plugin.getDateVoteMilestones(), never()).reload(); + var setup = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(timer).submit(setup.capture()); setup.getValue().run(); + verify(queues.constructed().getFirst(), never()).start(); + ready(plugin); + Runnable proxy = () -> { + verify(plugin.getDateVoteMilestones()).reload(); + assertEquals(3, registered.size()); + assertInstanceOf(com.bencodez.votingplugin.listeners.PlayerVoteListener.class, registered.get(1)); + assertInstanceOf(com.bencodez.votingplugin.timequeue.TimeQueueHandler.class, registered.get(2)); + }; + plugin.initializeDateVoteIngress(proxy); plugin.initializeDateVoteIngress(proxy); + assertEquals(3, registered.size()); + verify(plugin.getDateVoteMilestones()).reload(); verify(queues.constructed().getFirst()).start(); + } } } @@ -118,4 +105,18 @@ private static void ready(VotingPluginMain plugin) throws Exception { } } + @Test void failedProxyOpeningRetriesWithoutDuplicatingAcceptedConsumers() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ready(plugin); when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + var registered = new java.util.ArrayList(); + var eventManager = plugin.getServer().getPluginManager(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }) + .when(eventManager).registerEvents(any(), eq(plugin)); + assertThrows(IllegalStateException.class, () -> plugin.initializeDateVoteIngress(() -> { throw new IllegalStateException("transport unavailable"); })); + assertEquals(2, registered.size()); + plugin.initializeDateVoteIngress(() -> { }); plugin.initializeDateVoteIngress(() -> fail("Already ready")); + assertEquals(2, registered.size()); verify(plugin.getDateVoteMilestones(), times(2)).reload(); + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java new file mode 100644 index 0000000000..1da3b356f4 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -0,0 +1,204 @@ +package 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.*; +import java.util.concurrent.*; +import java.util.logging.Logger; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Listener; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.api.user.validation.*; +import com.bencodez.votingplugin.events.*; +import com.bencodez.votingplugin.listeners.*; +import com.bencodez.votingplugin.specialrewards.datemilestones.*; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.util.BoundedScheduledExecutor; +import com.bencodez.votingplugin.votesites.VoteSite; +import com.vexsoftware.votifier.model.Vote; + +/** Genuine NuVotifier input traverses actual startup capture, bounded workers and the accepted consumer. */ +class DateVotifierStartupCaptureTest { + @TempDir Path root; + private static final String SERVICE = "startup.example"; + private static final String[] HANDLERS = { "voteMilestonesManager", "voteStreakHandler", "voteParty", "specialRewards", "topVoterHandler", "voteShopManager", "placeholders" }; + static void ready(VotingPluginMain plugin) throws Exception { + for (String name : HANDLERS) { + var field = VotingPluginMain.class.getDeclaredField(name); field.setAccessible(true); field.set(plugin, mock(field.getType())); + } + } + private final class Fixture implements AutoCloseable { + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final BoundedScheduledExecutor timer = new BoundedScheduledExecutor(1, 256); + final VotingPluginUser user = mock(VotingPluginUser.class); + final VoteSite site = mock(VoteSite.class); + final YamlConfiguration config = new YamlConfiguration(); + final List registered = new CopyOnWriteArrayList<>(); + final List accepted = new CopyOnWriteArrayList<>(); + final List posted = new CopyOnWriteArrayList<>(); + final CountDownLatch delivered; + final DateVoteMilestones dates; + Fixture(Path directory, int expectedVotes) throws Exception { + delivered = new CountDownLatch(expectedVotes); + timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + timer.setThreadFactory(work -> new Thread(work, "date-startup-vote-owner")); + when(plugin.isEnabled()).thenReturn(true); when(plugin.isVotifierLoaded()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getVoteTimer()).thenReturn(timer); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + doCallRealMethod().when(plugin).getVotifierVoteOverflowQueue(); + doCallRealMethod().when(plugin).registerEarlyVotifierIngress(); + doCallRealMethod().when(plugin).initializeDateVoteIngress(any()); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + when(plugin.getUserManager().getProperName("Steve")).thenReturn("Steve"); + when(plugin.getUserManager().getValidationService().validate("Steve", false)).thenReturn( + new UserValidationResult(ValidationStatus.VALID, "Steve", ValidationSource.STORAGE, "fixture", false)); + UUID playerId = UUID.randomUUID(); + when(user.getPlayerName()).thenReturn("Steve"); when(user.getJavaUUID()).thenReturn(playerId); + when(user.getUUID()).thenReturn(playerId.toString()); when(user.isOnline()).thenReturn(true); + when(site.isEnabled()).thenReturn(true); when(site.getKey()).thenReturn("a"); when(site.getServiceSite()).thenReturn(SERVICE); + when(plugin.getVoteSiteManager().getVoteSiteName(false, SERVICE, "")).thenReturn("a"); + when(plugin.getVoteSiteManager().hasVoteSite("a")).thenReturn(true); + when(plugin.getVoteSiteManager().getVoteSiteName(true, SERVICE, "")).thenReturn("a"); + when(plugin.getVoteSiteManager().getVoteSite("a", true)).thenReturn(site); + String prefix = "DateVoteMilestones.startup."; + config.set(prefix + "Enabled", true); config.set(prefix + "Start", "2020-01-01T00:00:00"); + config.set(prefix + "End", "2099-01-01T00:00:00"); config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + dates = new DateVoteMilestones(plugin); when(plugin.getDateVoteMilestones()).thenReturn(dates); + var manager = plugin.getServer().getPluginManager(); + doAnswer(call -> { registered.add(call.getArgument(0)); return null; }).when(manager).registerEvents(any(), eq(plugin)); + doAnswer(call -> { + Object event = call.getArgument(0); + if (event instanceof PlayerVoteEvent vote) { + assertEquals("date-startup-vote-owner", Thread.currentThread().getName()); + vote.setVotingPluginUser(user); accepted.add(vote); + var consumer = registered.stream().filter(PlayerVoteListener.class::isInstance).map(PlayerVoteListener.class::cast).findFirst().orElseThrow(); + consumer.onplayerVote(vote); + } else if (event instanceof PlayerPostVoteEvent post) { posted.add(post); delivered.countDown(); } + return null; + }).when(manager).callEvent(any()); + } + VotiferEvent capture() { return registered.stream().filter(VotiferEvent.class::isInstance).map(VotiferEvent.class::cast).findFirst().orElseThrow(); } + void vote() { capture().onVotiferEvent(new com.vexsoftware.votifier.model.VotifierEvent(new Vote(SERVICE, "Steve", "127.0.0.1", "1"))); } + void drain() throws Exception { timer.submit(() -> { }).get(3, TimeUnit.SECONDS); } + void open() throws Exception { ready(plugin); plugin.initializeDateVoteIngress(() -> fail("Standalone does not open proxy ingress")); } + void received() throws Exception { assertTrue(delivered.await(3, TimeUnit.SECONDS)); drain(); } + DateVoteLedger.Progress progress() throws Exception { + var event = DateVoteMilestones.parse("startup", config.getConfigurationSection("DateVoteMilestones.startup")); + return new DateVoteLedger(plugin.getDataFolder().toPath().resolve("date-vote-milestones")).progress(event, user.getJavaUUID()); + } + public void close() { + capture().stop(); timer.shutdownNow(); + try { assertTrue(timer.awaitTermination(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new AssertionError(failure); } + var queue = plugin.getVotifierVoteOverflowQueue(); if (queue != null) queue.close(); + } + } + + @Test void actualExternalEventsAreCapturedBeforeConsumersAndDeliveredOnceAfterReadiness() throws Exception { + try (var fixture = new Fixture(root, 2)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); + assertEquals(1, fixture.registered.size()); + long before = System.currentTimeMillis(); fixture.vote(); fixture.vote(); long after = System.currentTimeMillis(); + fixture.drain(); + assertEquals(2, fixture.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(fixture.accepted.isEmpty()); + verify(fixture.plugin.getServerData(), never()).addServiceSite(anyString()); + verify(fixture.user, never()).cache(); verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + long openedAt = System.currentTimeMillis(); fixture.open(); fixture.plugin.initializeDateVoteIngress(() -> fail("Repeated startup cannot reopen transports")); + fixture.received(); fixture.vote(); fixture.drain(); + assertEquals(3, fixture.registered.size()); assertEquals(3, fixture.accepted.size()); assertEquals(3, fixture.posted.size()); + assertEquals(0, fixture.plugin.getVotifierVoteOverflowQueue().size()); + for (int index = 0; index < 2; index++) { + long occurrence = fixture.accepted.get(index).getCanonicalOccurrenceTime(); + assertTrue(occurrence >= before && occurrence <= after); + assertEquals(0L, fixture.accepted.get(index).getTime()); + assertEquals(occurrence, fixture.posted.get(index).getCanonicalOccurrenceTime()); + assertTrue(fixture.posted.get(index).getVoteTime() >= openedAt); + } + assertEquals(3, fixture.progress().votes()); + verify(fixture.user, times(3)).setTime(fixture.site); + verify(fixture.user, never()).setTime(eq(fixture.site), anyLong()); + verify(fixture.user, times(3)).addTotal(); verify(fixture.user, times(3)).playerVote(fixture.site, true, false); + verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } + } + + @Test void failedReadinessRetainsCaptureAndUsesCurrentDefinitionsWhenStartupRetries() throws Exception { + try (var fixture = new Fixture(root, 1)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + assertThrows(IllegalStateException.class, () -> fixture.plugin.initializeDateVoteIngress(() -> fail("Not ready"))); + assertEquals(1, fixture.registered.size()); assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); + // Reload/edit before release: a stale enabled snapshot must not award a buffered vote. + fixture.config.set("DateVoteMilestones.startup.Enabled", false); + fixture.plugin.registerEarlyVotifierIngress(); fixture.open(); fixture.received(); + assertEquals(1, fixture.accepted.size()); assertEquals(0, fixture.progress().votes()); + verify(fixture.user).addTotal(); verify(fixture.user).playerVote(fixture.site, true, false); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + } + + @Test void preReadyCaptureSurvivesRestartWithItsSeparateOccurrence() throws Exception { + long original; + try (var fixture = new Fixture(root, 0)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(fixture.accepted.isEmpty()); + // Closing the real overflow writes the captured receipt; no consumer was opened. + fixture.plugin.getVotifierVoteOverflowQueue().close(); + var yaml = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()); + original = ((Number) yaml.getMapList("Votes").getFirst().get("Time")).longValue(); + } + try (var restarted = new Fixture(root, 1)) { + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); + assertEquals(1, restarted.plugin.getVotifierVoteOverflowQueue().size()); assertTrue(restarted.accepted.isEmpty()); + restarted.open(); restarted.received(); + assertEquals(1, restarted.accepted.size()); assertEquals(original, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, restarted.accepted.getFirst().getTime()); assertEquals(original, restarted.posted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(1, restarted.progress().votes()); verify(restarted.user).addTotal(); + } + } + + @Test void retiredCaptureCannotReopenAnOldStartupGeneration() throws Exception { + try (var fixture = new Fixture(root, 0)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + fixture.capture().stop(); + var field = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); field.setAccessible(true); field.setBoolean(fixture.plugin, true); + fixture.vote(); fixture.drain(); + assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); + assertThrows(IllegalStateException.class, () -> fixture.plugin.initializeDateVoteIngress(() -> fail("Retired"))); + assertTrue(fixture.accepted.isEmpty()); assertEquals(1, fixture.registered.size()); + } + } + + @Test void admittedOverflowCallbackCannotRewardAfterStartupGenerationRetires() throws Exception { + try (var fixture = new Fixture(root, 0)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + fixture.timer.submit(() -> { + entered.countDown(); + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } catch (InterruptedException failure) { throw new AssertionError(failure); } + }); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { + fixture.open(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + // The constructor's delayed replay occupies one slot; overflow is another. + while (fixture.timer.getQueue().size() < 2 && System.nanoTime() < deadline) Thread.sleep(5); + assertTrue(fixture.timer.getQueue().size() >= 2); + fixture.capture().stop(); + var field = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); + field.setAccessible(true); field.setBoolean(fixture.plugin, true); + } finally { release.countDown(); } + fixture.drain(); + assertTrue(fixture.accepted.isEmpty()); assertEquals(1, fixture.plugin.getVotifierVoteOverflowQueue().size()); + verify(fixture.user, never()).addTotal(); verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + } + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index b667f52987..0ca05cac11 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -148,11 +148,11 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true); + when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(overflowQueue).enqueue("Steve", SERVICE_SITE); + verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 5f5618348f..3032ea9f17 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -108,4 +108,48 @@ void rejectedProcessingSchedulesOneBoundedRetry() { org.mockito.ArgumentMatchers.eq(TimeUnit.SECONDS)); assertEquals(1, handler.getTimeChangeQueue().size()); } + + @Test + void actualCacheWriterAndReplayKeepProcessingAndOccurrenceClocksSeparate() throws Exception { + var stored = new org.bukkit.configuration.file.YamlConfiguration(); + ServerData writer = org.mockito.Mockito.spy(new ServerData(plugin)); + org.mockito.Mockito.doReturn(stored).when(writer).getData(); + org.mockito.Mockito.doNothing().when(writer).saveData(); + var captured = new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Steve", "example.org", 789L); + captured.setCanonicalOccurrenceTime(456L); + writer.addTimeVoted(0, captured); + writer.addTimeVoted(1, new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Alex", "example.org", 123L)); + assertEquals(789L, stored.getLong("TimedVoteCache.0.Time")); + assertEquals(456L, stored.getLong("TimedVoteCache.0.CanonicalOccurrenceTime")); + org.junit.jupiter.api.Assertions.assertFalse(stored.contains("TimedVoteCache.1.CanonicalOccurrenceTime")); + var restored = new org.bukkit.configuration.file.YamlConfiguration(); + restored.loadFromString(stored.saveToString()); + // Malformed optional metadata fails Date provenance, retaining ordinary replay. + restored.set("TimedVoteCache.2.Name", "Invalid"); restored.set("TimedVoteCache.2.Service", "example.org"); + restored.set("TimedVoteCache.2.Time", 321L); restored.set("TimedVoteCache.2.CanonicalOccurrenceTime", "bad"); + org.mockito.Mockito.doReturn(restored).when(writer).getData(); + when(plugin.getServerData()).thenReturn(writer); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + var replayed = new java.util.ArrayList(); + var eventManager = plugin.getServer().getPluginManager(); + org.mockito.Mockito.doAnswer(call -> { replayed.add(call.getArgument(0)); return null; }) + .when(eventManager).callEvent(any()); + handler.processQueue(); + assertEquals(3, replayed.size()); + var byName = replayed.stream().collect(java.util.stream.Collectors.toMap(PlayerVoteEvent::getPlayer, event -> event)); + assertEquals(789L, byName.get("Steve").getTime()); assertEquals(Long.valueOf(456L), byName.get("Steve").getCanonicalOccurrenceTime()); + assertEquals(123L, byName.get("Alex").getTime()); org.junit.jupiter.api.Assertions.assertNull(byName.get("Alex").getCanonicalOccurrenceTime()); + assertEquals(321L, byName.get("Invalid").getTime()); assertEquals(Long.valueOf(-1L), byName.get("Invalid").getCanonicalOccurrenceTime()); + } + + @Test + void timeChangeAdmissionPreservesCapturedReceiptWithoutReplacingQueueTime() { + when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + long before = System.currentTimeMillis(); handler.addVote("Steve", "example.org", 456L); long after = System.currentTimeMillis(); + var queued = handler.getTimeChangeQueue().element(); + org.junit.jupiter.api.Assertions.assertTrue(queued.getTime() >= before && queued.getTime() <= after); + assertEquals(456L, queued.getCanonicalOccurrenceTime()); + } + } diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index fab3369d3f..14dca31f12 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -68,7 +68,7 @@ Canonical date accounting requires an explicit transport vote ID; a legacy total Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. -Local Votifier and accepted-vote listeners are registered after date definitions and all normal reward handlers are ready, alongside the existing proxy-ingress readiness boundary. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. +Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries); queue-file loading and persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key while preserving the established `Time` field and legacy records. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. From 26441c1378f75057b74eb0836d7cea6431234ec1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:37:49 -0600 Subject: [PATCH 13/22] Persist local vote occurrence identities across queue replay --- .../votingplugin/VotingPluginMain.java | 2 +- .../core/vote/SharedVoteProcessor.java | 8 +- .../votingplugin/data/ServerData.java | 8 ++ .../votingplugin/events/PlayerVoteEvent.java | 5 ++ .../listeners/PlayerVoteListener.java | 1 + .../votingplugin/listeners/VotiferEvent.java | 20 +++-- .../listeners/VotifierVoteOverflowQueue.java | 44 +++++++++-- .../timequeue/TimeQueueHandler.java | 28 +++++++ .../votingplugin/timequeue/VoteTimeQueue.java | 4 + .../DateVotifierStartupCaptureTest.java | 56 +++++++++++++- .../core/vote/SharedVoteProcessorTest.java | 14 ++++ .../VotiferEventDisabledVoteSiteTest.java | 4 +- .../VotifierVoteOverflowQueueTest.java | 41 +++++++++++ .../TimeQueueHandlerRejectionTest.java | 73 ++++++++++++++++++- docs/date-vote-milestones.md | 4 +- 15 files changed, 287 insertions(+), 25 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 16411dae1f..56063f0159 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -2066,7 +2066,7 @@ void registerEarlyVotifierIngress() { // setup ahead of every captured vote, without a second startup collection. getVoteTimer().submit(() -> { VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(this, - (VotifierVoteOverflowQueue.VoteProcessor) listener::processVote, + (VotifierVoteOverflowQueue.OccurrenceProcessor) listener::processVote, () -> localVotifierIngressReady && !localVotifierIngressClosed); votifierVoteOverflowQueue = queue; if (localVotifierIngressClosed) queue.close(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java index bb373a8c02..d19510bf61 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessor.java @@ -72,6 +72,8 @@ public interface Operations { void broadcast(UUID uuid, String name, String siteDisplayName, boolean online); boolean hasProxyTextTotals(); UUID proxyVoteId(); + /** Local receipt identity, without implying proxy provenance. */ + default UUID localOccurrenceId() { return null; } default boolean identifiedQueuedProxyVote() { return false; } default boolean proxyQueueClassificationKnown() { return false; } default boolean proxyDelayValidationKnown() { return false; } @@ -186,10 +188,8 @@ public static void process(Operations ops) { + (legacyRecordedProxyVote ? "; legacy timestamp matches LastVotes: " + ops.incomingTime() : "; stable vote ID identifies this queued delivery: " + ops.proxyVoteId())); } - UUID voteId = UUID.randomUUID(); - if (ops.proxyVote() && ops.proxyVoteId() != null) { - voteId = ops.proxyVoteId(); - } + UUID voteId = ops.proxyVote() ? ops.proxyVoteId() : ops.localOccurrenceId(); + if (voteId == null) voteId = UUID.randomUUID(); String userId = ops.userId(user); ops.cache(user); ops.updateName(user); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index b287765823..11c89e1068 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -90,6 +90,8 @@ public void addTimeVoted(int num, VoteTimeQueue vote) { getData().set("TimedVoteCache." + num + ".Name", vote.getName()); getData().set("TimedVoteCache." + num + ".Service", vote.getService()); getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); + getData().set("TimedVoteCache." + num + ".LocalOccurrenceId", + vote.getLocalOccurrenceId() == null ? null : vote.getLocalOccurrenceId().toString()); getData().set("TimedVoteCache." + num + ".CanonicalOccurrenceTime", vote.getCanonicalOccurrenceTime() == 0L ? null : vote.getCanonicalOccurrenceTime()); saveData(); @@ -104,6 +106,12 @@ public void addVoteShopPurchase(String ident) { setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1)); } + /** Assign a legacy local cache row its identity before replay is scheduled. */ + public void setTimedVoteLocalOccurrenceId(String key, UUID occurrenceId) { + getData().set("TimedVoteCache." + key + ".LocalOccurrenceId", occurrenceId.toString()); + saveData(); + } + /** * Clears the timed vote cache. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java index 34cd2e9085..e6bd0aee08 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerVoteEvent.java @@ -14,6 +14,11 @@ public class PlayerVoteEvent extends Event { + /** Stable local receipt identity, separate from proxy transport provenance. */ + @Getter + @Setter + private UUID localOccurrenceId; + /** Original accepted occurrence time; ordinary cooldown/event time remains separate. */ @Getter @Setter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java index 5293a8f576..5b803c2ebf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/PlayerVoteListener.java @@ -128,6 +128,7 @@ private BukkitOperations(VotingPluginMain plugin, PlayerVoteEvent event) { } @Override public boolean hasProxyTextTotals() { return event.getBungeeTextTotals() != null; } @Override public UUID proxyVoteId() { return resolveProxyVoteId(event); } + @Override public UUID localOccurrenceId() { return event.getLocalOccurrenceId(); } @Override public boolean identifiedQueuedProxyVote() { return event.isQueuedProxyVote(); } @Override public boolean proxyQueueClassificationKnown() { return event.isProxyQueueClassificationKnown(); } @Override public boolean proxyDelayValidationKnown() { return event.isProxyDelayValidationKnown(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 3f12e0d832..3c849bdba4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.listeners; import java.util.concurrent.RejectedExecutionException; +import java.util.UUID; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -43,6 +44,11 @@ public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier public void processVote(String voteSite, String voteUsername) { processVote(voteSite, voteUsername, 0L); } public void processVote(String voteSite, String voteUsername, long occurredAt) { + processVote(voteSite, voteUsername, occurredAt, UUID.randomUUID()); + } + + public void processVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { + java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId"); try { plugin.getServerData().addServiceSite(voteSite); if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() @@ -84,7 +90,7 @@ public void processVote(String voteSite, String voteUsername, long occurredAt) { if (plugin.getTimeChecker().isActiveProcessing() && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); - plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt); + plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt, localOccurrenceId); return; } @@ -92,6 +98,7 @@ public void processVote(String voteSite, String voteUsername, long occurredAt) { PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(voteSiteName, true), voteUsername, voteSite, true); + voteEvent.setLocalOccurrenceId(localOccurrenceId); if (occurredAt > 0) voteEvent.setCanonicalOccurrenceTime(occurredAt); plugin.getServer().getPluginManager().callEvent(voteEvent); @@ -113,6 +120,7 @@ public void processVote(String voteSite, String voteUsername, long occurredAt) { public void onVotiferEvent(VotifierEvent event) { if (!accepting) return; final long occurredAt = System.currentTimeMillis(); + final UUID localOccurrenceId = UUID.randomUUID(); Vote vote = event.getVote(); String str = vote.getServiceName(); @@ -147,17 +155,17 @@ public void onVotiferEvent(VotifierEvent event) { try { plugin.getVoteTimer().submit(() -> { - if (ready.getAsBoolean() && accepting) processVote(voteSite, voteUsername, occurredAt); - else bufferVote(voteUsername, voteSite, occurredAt); + if (ready.getAsBoolean() && accepting) processVote(voteSite, voteUsername, occurredAt, localOccurrenceId); + else bufferVote(voteUsername, voteSite, occurredAt, localOccurrenceId); }); } catch (RejectedExecutionException rejected) { - bufferVote(voteUsername, voteSite, occurredAt); + bufferVote(voteUsername, voteSite, occurredAt, localOccurrenceId); } } - private void bufferVote(String username, String site, long occurredAt) { + private void bufferVote(String username, String site, long occurredAt, UUID localOccurrenceId) { VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(username, site, occurredAt)) { + if (overflow == null || !overflow.enqueue(username, site, occurredAt, localOccurrenceId)) { plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " + MinecraftUsernameValidator.sanitizeForLog(username)); } else { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index e32464aa19..1a0a2d2dd7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -17,6 +17,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.UUID; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -44,7 +45,9 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { private final VotingPluginMain plugin; @FunctionalInterface public interface VoteProcessor { void process(String serviceSite, String username, long occurredAt); } - private final VoteProcessor processor; + @FunctionalInterface + public interface OccurrenceProcessor { void process(String serviceSite, String username, long occurredAt, UUID localOccurrenceId); } + private final OccurrenceProcessor processor; private final java.util.function.BooleanSupplier processingAllowed; private final Path file; private final ScheduledThreadPoolExecutor worker; @@ -66,11 +69,16 @@ public interface VoteProcessor { void process(String serviceSite, String usernam * @param processor callback receiving service site and player name */ public VotifierVoteOverflowQueue(VotingPluginMain plugin, BiConsumer processor) { - this(plugin, (site, user, occurredAt) -> processor.accept(site, user), () -> true); + this(plugin, (site, user, occurredAt, id) -> processor.accept(site, user), () -> true); } /** Additive callback retains receipt occurrence and fences a retired startup generation. */ public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processor, + java.util.function.BooleanSupplier processingAllowed) { + this(plugin, (site, user, time, id) -> processor.process(site, user, time), processingAllowed); + } + + public VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed) { this.plugin = plugin; this.processor = processor; @@ -111,10 +119,14 @@ public boolean enqueue(String username, String serviceSite) { } public boolean enqueue(String username, String serviceSite, long occurredAt) { - if (username == null || serviceSite == null || occurredAt <= 0) return false; + return enqueue(username, serviceSite, occurredAt, UUID.randomUUID()); + } + + public boolean enqueue(String username, String serviceSite, long occurredAt, UUID localOccurrenceId) { + if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false; synchronized (lock) { if (closed || entries.size() >= MAX_ENTRIES) return false; - entries.addLast(new PendingVote(username, serviceSite, occurredAt)); + entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId)); stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -171,7 +183,7 @@ private void drain() { return; // Retired generation retains the durable entry for restart. } try { - processor.process(pending.serviceSite, pending.username, pending.time); + processor.process(pending.serviceSite, pending.username, pending.time, pending.localOccurrenceId); } finally { acknowledge(pending); } @@ -283,7 +295,22 @@ private void load() { skipped = true; continue; } - entries.addLast(new PendingVote(name, site, timestamp.longValue())); + UUID localOccurrenceId; + if (!map.containsKey("LocalOccurrenceId")) { + localOccurrenceId = UUID.randomUUID(); + skipped = true; // Publish the migration before admitting any recovered row. + } else { + Object rawId = map.get("LocalOccurrenceId"); + try { + if (!(rawId instanceof String id)) throw new IllegalArgumentException(); + localOccurrenceId = UUID.fromString(id); + if (!localOccurrenceId.toString().equalsIgnoreCase(id)) throw new IllegalArgumentException(); + } catch (IllegalArgumentException malformed) { + skipped = true; + continue; // Explicit corrupt IDs cannot become fresh occurrences. + } + } + entries.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId)); } if (skipped) { synchronized (lock) { @@ -323,6 +350,7 @@ private void writeSnapshotLocked(List snapshot) throws IOException value.put("Username", pending.username); value.put("ServiceSite", pending.serviceSite); value.put("Time", pending.time); + value.put("LocalOccurrenceId", pending.localOccurrenceId.toString()); values.add(value); } yaml.set("Votes", values); @@ -377,12 +405,14 @@ private static final class PendingVote { private final String username; private final String serviceSite; private final long time; + private final UUID localOccurrenceId; private boolean submitted; - private PendingVote(String username, String serviceSite, long time) { + private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId) { this.username = username; this.serviceSite = serviceSite; this.time = time; + this.localOccurrenceId = localOccurrenceId; } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index f13965d3a5..7c8e12bee7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -3,6 +3,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Queue; +import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -51,8 +52,14 @@ public TimeQueueHandler(VotingPluginMain plugin) { public void addVote(String voteUsername, String voteSiteName) { addVote(voteUsername, voteSiteName, 0L); } public void addVote(String voteUsername, String voteSiteName, long occurredAt) { + addVote(voteUsername, voteSiteName, occurredAt, UUID.randomUUID()); + } + + public void addVote(String voteUsername, String voteSiteName, long occurredAt, UUID localOccurrenceId) { + java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId"); VoteTimeQueue queued = new VoteTimeQueue(voteUsername, voteSiteName, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); + queued.setLocalOccurrenceId(localOccurrenceId); if (occurredAt > 0) queued.setCanonicalOccurrenceTime(occurredAt); timeChangeQueue.add(queued); } @@ -64,6 +71,26 @@ public void load() { for (String str : plugin.getServerData().getTimedVoteCacheKeys()) { ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str); VoteTimeQueue queued = new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time")); + UUID localOccurrenceId; + if (!data.contains("LocalOccurrenceId")) { + // The existing ServerData saver can defer publication. Derive only + // legacy migration IDs from their persisted row identity so a crash + // before that save completes cannot reroll the same legacy row. + String identity = "legacy-local-timed-vote\u0000" + str + "\u0000" + queued.getName() + + "\u0000" + queued.getService() + "\u0000" + queued.getTime(); + localOccurrenceId = UUID.nameUUIDFromBytes(identity.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + plugin.getServerData().setTimedVoteLocalOccurrenceId(str, localOccurrenceId); + } else { + String rawId = data.getString("LocalOccurrenceId"); + try { + localOccurrenceId = UUID.fromString(rawId); + if (!localOccurrenceId.toString().equalsIgnoreCase(rawId)) throw new IllegalArgumentException(); + } catch (IllegalArgumentException | NullPointerException malformed) { + plugin.getLogger().warning("Invalid local occurrence ID in timed vote cache; row withheld"); + continue; + } + } + queued.setLocalOccurrenceId(localOccurrenceId); if (data.contains("CanonicalOccurrenceTime")) queued.setCanonicalOccurrenceTime(data.getLong("CanonicalOccurrenceTime", -1L)); timeChangeQueue.add(queued); } @@ -121,6 +148,7 @@ public void processQueue() { plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), vote.getService(), true); voteEvent.setTime(vote.getTime()); + voteEvent.setLocalOccurrenceId(vote.getLocalOccurrenceId()); if (vote.getCanonicalOccurrenceTime() != 0L) voteEvent.setCanonicalOccurrenceTime(vote.getCanonicalOccurrenceTime()); plugin.getServer().getPluginManager().callEvent(voteEvent); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java index 154302129f..3af7f85c48 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java @@ -30,6 +30,10 @@ public class VoteTimeQueue { @Getter @Setter private UUID voteId; + /** Local Bukkit receipt identity; never used as a proxy transport vote ID. */ + @Getter + @Setter + private UUID localOccurrenceId; /** SQL primary key for a legacy timed-cache row, or {@code -1} when not SQL-backed. */ @Getter @Setter diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java index 1da3b356f4..fb6001e8b9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -42,7 +42,9 @@ private final class Fixture implements AutoCloseable { final List posted = new CopyOnWriteArrayList<>(); final CountDownLatch delivered; final DateVoteMilestones dates; - Fixture(Path directory, int expectedVotes) throws Exception { + volatile Runnable afterAccepted = () -> { }; + Fixture(Path directory, int expectedVotes) throws Exception { this(directory, expectedVotes, UUID.randomUUID()); } + Fixture(Path directory, int expectedVotes, UUID playerId) throws Exception { delivered = new CountDownLatch(expectedVotes); timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); timer.setThreadFactory(work -> new Thread(work, "date-startup-vote-owner")); @@ -58,7 +60,6 @@ private final class Fixture implements AutoCloseable { when(plugin.getUserManager().getProperName("Steve")).thenReturn("Steve"); when(plugin.getUserManager().getValidationService().validate("Steve", false)).thenReturn( new UserValidationResult(ValidationStatus.VALID, "Steve", ValidationSource.STORAGE, "fixture", false)); - UUID playerId = UUID.randomUUID(); when(user.getPlayerName()).thenReturn("Steve"); when(user.getJavaUUID()).thenReturn(playerId); when(user.getUUID()).thenReturn(playerId.toString()); when(user.isOnline()).thenReturn(true); when(site.isEnabled()).thenReturn(true); when(site.getKey()).thenReturn("a"); when(site.getServiceSite()).thenReturn(SERVICE); @@ -81,6 +82,7 @@ private final class Fixture implements AutoCloseable { vote.setVotingPluginUser(user); accepted.add(vote); var consumer = registered.stream().filter(PlayerVoteListener.class::isInstance).map(PlayerVoteListener.class::cast).findFirst().orElseThrow(); consumer.onplayerVote(vote); + afterAccepted.run(); } else if (event instanceof PlayerPostVoteEvent post) { posted.add(post); delivered.countDown(); } return null; }).when(manager).callEvent(any()); @@ -122,6 +124,9 @@ public void close() { assertEquals(occurrence, fixture.posted.get(index).getCanonicalOccurrenceTime()); assertTrue(fixture.posted.get(index).getVoteTime() >= openedAt); } + assertEquals(3, fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + assertTrue(fixture.accepted.stream().allMatch(vote -> vote.getLocalOccurrenceId() != null && vote.getProxyVoteId() == null)); + for (int index = 0; index < 3; index++) assertEquals(fixture.accepted.get(index).getLocalOccurrenceId(), fixture.posted.get(index).getVoteUUID()); assertEquals(3, fixture.progress().votes()); verify(fixture.user, times(3)).setTime(fixture.site); verify(fixture.user, never()).setTime(eq(fixture.site), anyLong()); @@ -164,6 +169,53 @@ public void close() { } } + @Test void crashAfterLedgerBeforeOverflowAckReplaysTheSameOccurrenceWithoutAnotherAward() throws Exception { + UUID playerId = UUID.randomUUID(); + UUID original; + long originalTime; + var committed = new CountDownLatch(1); + var release = new CountDownLatch(1); + try (var fixture = new Fixture(root, 1, playerId)) { + // Recounting a replay would unlock a distinct second reward. + fixture.config.set("DateVoteMilestones.startup.Milestones.2.Rewards.Messages.Player", "Second"); + fixture.afterAccepted = () -> { + committed.countDown(); + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + }; + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); + fixture.open(); + assertTrue(committed.await(3, TimeUnit.SECONDS)); + try { + assertEquals(1, fixture.progress().votes()); + original = fixture.accepted.getFirst().getLocalOccurrenceId(); + originalTime = fixture.accepted.getFirst().getCanonicalOccurrenceTime(); + // The native callback has not returned, so existing shutdown retains + // precisely the same durable receipt a process crash would leave. + fixture.plugin.getVotifierVoteOverflowQueue().close(); + var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()); + assertEquals(original.toString(), disk.getMapList("Votes").getFirst().get("LocalOccurrenceId")); + verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } finally { release.countDown(); } + fixture.drain(); + } + try (var restarted = new Fixture(root, 1, playerId)) { + restarted.config.set("DateVoteMilestones.startup.Milestones.2.Rewards.Messages.Player", "Second"); + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); restarted.open(); restarted.received(); + assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(original, restarted.posted.getFirst().getVoteUUID()); + assertNull(restarted.accepted.getFirst().getProxyVoteId()); + assertFalse(restarted.accepted.getFirst().isBungee()); + assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, restarted.accepted.getFirst().getTime()); + assertEquals(1, restarted.progress().votes()); + verify(restarted.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + // Ordinary effects retain their existing at-least-once replay semantics. + verify(restarted.user).setTime(restarted.site); + verify(restarted.user).addTotal(); verify(restarted.user).playerVote(restarted.site, true, false); + } + } + @Test void retiredCaptureCannotReopenAnOldStartupGeneration() throws Exception { try (var fixture = new Fixture(root, 0)) { fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java index c5f10a73d9..f6a3c3abbb 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/core/vote/SharedVoteProcessorTest.java @@ -92,6 +92,20 @@ void standaloneDateMembershipUsesActualInstantDuringDstRollback() { verify(ops).dateMilestones(eq(user), eq(site), eq(instant), any(UUID.class)); } + @Test + void localOccurrenceIdentityKeepsOrdinaryProcessingClockAndDoesNotTriggerProxyBehavior() { + var ops = accepted(); + UUID localId = UUID.randomUUID(); + when(ops.localOccurrenceId()).thenReturn(localId); + when(ops.proxyVoteId()).thenReturn(UUID.randomUUID()); // An irrelevant proxy field cannot override local identity. + when(ops.currentTimeMillis()).thenReturn(789L); + SharedVoteProcessor.process(ops); + verify(ops).setTimeNow(user, site); + verify(ops, never()).triggerProxyEvent(); + verify(ops).dateMilestones(user, site, 789L, localId); + verify(ops).postVote(site, user, "Ben", 789L, localId, true); + } + @Test void proxyVoteUsesHistoricalOnlineForDeliveryButCurrentOnlineForTotals() { var ops = accepted(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index 0ca05cac11..b0c074f551 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -148,11 +148,11 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class))).thenReturn(true); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong()); + verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class)); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 6c3994cf37..881d093ccc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -255,6 +255,47 @@ void stalePersistenceWorkerCannotOverwriteShutdownSnapshot(@TempDir Path dataFol } } + @Test + void legacyRowsReceiveDistinctDurableIdsBeforeDrainAndMalformedIdsFailClosed(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ScheduledExecutorService voteTimer = Executors.newSingleThreadScheduledExecutor(); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getVoteTimer()).thenReturn(voteTimer); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + Files.writeString(file, """ + Votes: + - {Username: Steve, ServiceSite: example.org, Time: 123} + - {Username: Steve, ServiceSite: example.org, Time: 123} + - {Username: Steve, ServiceSite: example.org, Time: 123, LocalOccurrenceId: bad} + - {Username: Steve, ServiceSite: example.org, Time: 123, LocalOccurrenceId: 1-1-1-1-1} + - {Username: Steve, ServiceSite: example.org, Time: 123, LocalOccurrenceId: 12} + """); + var received = new java.util.concurrent.CopyOnWriteArrayList(); + var done = new CountDownLatch(2); + VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(plugin, + (site, user, time, id) -> { received.add(id); done.countDown(); }, () -> true); + try { + assertEquals(2, queue.size()); + // Closing before admission forces the automatic legacy migration to disk. + queue.close(); + var durable = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file.toFile()).getMapList("Votes"); + assertEquals(2, durable.size()); + var expected = durable.stream().map(row -> java.util.UUID.fromString((String) row.get("LocalOccurrenceId"))).toList(); + assertEquals(2, expected.stream().distinct().count(), "identical historical receipt fields are distinct rows"); + try (var restarted = new VotifierVoteOverflowQueue(plugin, (site, user, time, id) -> { + var admitted = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(file.toFile()).getMapList("Votes"); + assertTrue(admitted.stream().anyMatch(row -> id.toString().equals(row.get("LocalOccurrenceId"))), + "the occurrence must be published before admission"); + assertEquals(123L, time); + received.add(id); done.countDown(); + }, () -> true)) { + restarted.start(); + assertTrue(done.await(3, TimeUnit.SECONDS)); + assertEquals(expected, received); + } + } finally { queue.close(); voteTimer.shutdownNow(); } + } + private static void waitForFile(Path file) throws Exception { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); while (!Files.exists(file) && System.nanoTime() < deadline) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 3032ea9f17..26622e37cf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -116,9 +116,12 @@ void actualCacheWriterAndReplayKeepProcessingAndOccurrenceClocksSeparate() throw org.mockito.Mockito.doReturn(stored).when(writer).getData(); org.mockito.Mockito.doNothing().when(writer).saveData(); var captured = new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Steve", "example.org", 789L); + java.util.UUID occurrenceId = java.util.UUID.randomUUID(); + captured.setLocalOccurrenceId(occurrenceId); captured.setCanonicalOccurrenceTime(456L); writer.addTimeVoted(0, captured); writer.addTimeVoted(1, new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Alex", "example.org", 123L)); + assertEquals(occurrenceId.toString(), stored.getString("TimedVoteCache.0.LocalOccurrenceId")); assertEquals(789L, stored.getLong("TimedVoteCache.0.Time")); assertEquals(456L, stored.getLong("TimedVoteCache.0.CanonicalOccurrenceTime")); org.junit.jupiter.api.Assertions.assertFalse(stored.contains("TimedVoteCache.1.CanonicalOccurrenceTime")); @@ -127,6 +130,10 @@ void actualCacheWriterAndReplayKeepProcessingAndOccurrenceClocksSeparate() throw // Malformed optional metadata fails Date provenance, retaining ordinary replay. restored.set("TimedVoteCache.2.Name", "Invalid"); restored.set("TimedVoteCache.2.Service", "example.org"); restored.set("TimedVoteCache.2.Time", 321L); restored.set("TimedVoteCache.2.CanonicalOccurrenceTime", "bad"); + restored.set("TimedVoteCache.3.Name", "Corrupt"); restored.set("TimedVoteCache.3.Service", "example.org"); + restored.set("TimedVoteCache.3.Time", 654L); restored.set("TimedVoteCache.3.LocalOccurrenceId", "bad"); + restored.set("TimedVoteCache.4.Name", "ShortId"); restored.set("TimedVoteCache.4.Service", "example.org"); + restored.set("TimedVoteCache.4.Time", 654L); restored.set("TimedVoteCache.4.LocalOccurrenceId", "1-1-1-1-1"); org.mockito.Mockito.doReturn(restored).when(writer).getData(); when(plugin.getServerData()).thenReturn(writer); TimeQueueHandler handler = new TimeQueueHandler(plugin); @@ -134,22 +141,86 @@ void actualCacheWriterAndReplayKeepProcessingAndOccurrenceClocksSeparate() throw var eventManager = plugin.getServer().getPluginManager(); org.mockito.Mockito.doAnswer(call -> { replayed.add(call.getArgument(0)); return null; }) .when(eventManager).callEvent(any()); + var migratedId = handler.getTimeChangeQueue().stream().filter(vote -> "Alex".equals(vote.getName())) + .findFirst().orElseThrow().getLocalOccurrenceId(); + assertEquals(migratedId.toString(), restored.getString("TimedVoteCache.1.LocalOccurrenceId")); + var secondResume = new TimeQueueHandler(plugin); + assertEquals(migratedId, secondResume.getTimeChangeQueue().stream().filter(vote -> "Alex".equals(vote.getName())) + .findFirst().orElseThrow().getLocalOccurrenceId()); handler.processQueue(); assertEquals(3, replayed.size()); var byName = replayed.stream().collect(java.util.stream.Collectors.toMap(PlayerVoteEvent::getPlayer, event -> event)); + assertEquals(occurrenceId, byName.get("Steve").getLocalOccurrenceId()); + org.junit.jupiter.api.Assertions.assertNull(byName.get("Steve").getProxyVoteId()); + org.junit.jupiter.api.Assertions.assertFalse(byName.get("Steve").isBungee()); + assertEquals(migratedId, byName.get("Alex").getLocalOccurrenceId()); assertEquals(789L, byName.get("Steve").getTime()); assertEquals(Long.valueOf(456L), byName.get("Steve").getCanonicalOccurrenceTime()); assertEquals(123L, byName.get("Alex").getTime()); org.junit.jupiter.api.Assertions.assertNull(byName.get("Alex").getCanonicalOccurrenceTime()); assertEquals(321L, byName.get("Invalid").getTime()); assertEquals(Long.valueOf(-1L), byName.get("Invalid").getCanonicalOccurrenceTime()); } + @Test + void actualAdmissionSaveAndRestartReplayRetainLocalId() throws Exception { + var stored = new org.bukkit.configuration.file.YamlConfiguration(); + ServerData writer = org.mockito.Mockito.spy(new ServerData(plugin)); + org.mockito.Mockito.doReturn(stored).when(writer).getData(); + org.mockito.Mockito.doNothing().when(writer).saveData(); + when(plugin.getServerData()).thenReturn(writer); + TimeQueueHandler original = new TimeQueueHandler(plugin); + java.util.UUID occurrenceId = java.util.UUID.randomUUID(); + original.addVote("Steve", "example.org", 456L, occurrenceId); + long queueTime = original.getTimeChangeQueue().element().getTime(); + original.save(); + var restored = new org.bukkit.configuration.file.YamlConfiguration(); + restored.loadFromString(stored.saveToString()); + org.mockito.Mockito.doReturn(restored).when(writer).getData(); + TimeQueueHandler resumed = new TimeQueueHandler(plugin); + org.mockito.ArgumentCaptor replay = org.mockito.ArgumentCaptor.forClass(PlayerVoteEvent.class); + resumed.processQueue(); + verify(plugin.getServer().getPluginManager()).callEvent(replay.capture()); + assertEquals(occurrenceId, replay.getValue().getLocalOccurrenceId()); + assertEquals(queueTime, replay.getValue().getTime()); + assertEquals(Long.valueOf(456L), replay.getValue().getCanonicalOccurrenceTime()); + org.junit.jupiter.api.Assertions.assertNull(replay.getValue().getProxyVoteId()); + } + + @Test + void legacyTimedIdsAreStableEvenIfSaveHasNotPublishedAndIdenticalRowsRemainDistinct() throws Exception { + var legacy = new org.bukkit.configuration.file.YamlConfiguration(); + for (String key : java.util.List.of("0", "1")) { + legacy.set("TimedVoteCache." + key + ".Name", "Steve"); + legacy.set("TimedVoteCache." + key + ".Service", "example.org"); + legacy.set("TimedVoteCache." + key + ".Time", 123L); + } + String beforeMigration = legacy.saveToString(); + ServerData writer = org.mockito.Mockito.spy(new ServerData(plugin)); + org.mockito.Mockito.doReturn(legacy).when(writer).getData(); + org.mockito.Mockito.doNothing().when(writer).saveData(); + when(plugin.getServerData()).thenReturn(writer); + var first = new TimeQueueHandler(plugin).getTimeChangeQueue().stream() + .map(com.bencodez.votingplugin.timequeue.VoteTimeQueue::getLocalOccurrenceId).toList(); + assertEquals(2, first.stream().distinct().count()); + org.mockito.Mockito.verify(writer, org.mockito.Mockito.times(2)).saveData(); + // Simulate a restart that sees only the old on-disk representation. + var unpublished = new org.bukkit.configuration.file.YamlConfiguration(); + unpublished.loadFromString(beforeMigration); + org.mockito.Mockito.doReturn(unpublished).when(writer).getData(); + var second = new TimeQueueHandler(plugin).getTimeChangeQueue().stream() + .map(com.bencodez.votingplugin.timequeue.VoteTimeQueue::getLocalOccurrenceId).toList(); + assertEquals(first, second); + } + @Test void timeChangeAdmissionPreservesCapturedReceiptWithoutReplacingQueueTime() { when(serverData.getTimedVoteCacheKeys()).thenReturn(Set.of()); TimeQueueHandler handler = new TimeQueueHandler(plugin); - long before = System.currentTimeMillis(); handler.addVote("Steve", "example.org", 456L); long after = System.currentTimeMillis(); + java.util.UUID occurrenceId = java.util.UUID.randomUUID(); + long before = System.currentTimeMillis(); handler.addVote("Steve", "example.org", 456L, occurrenceId); long after = System.currentTimeMillis(); var queued = handler.getTimeChangeQueue().element(); org.junit.jupiter.api.Assertions.assertTrue(queued.getTime() >= before && queued.getTime() <= after); assertEquals(456L, queued.getCanonicalOccurrenceTime()); + assertEquals(occurrenceId, queued.getLocalOccurrenceId()); + org.junit.jupiter.api.Assertions.assertNull(queued.getVoteId()); } } diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 14dca31f12..8cbcabee9a 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -36,7 +36,7 @@ Reload after configuration. `/vote dateevents` displays the player's event count Start is inclusive; end is exclusive. Values are local ISO date-times interpreted with the explicit IANA timezone, such as `UTC` or `America/New_York`. Nonexistent or ambiguous daylight-saving boundaries are rejected; choose an unambiguous boundary. Invalid events are disabled with an explanation without disabling valid sibling events. Stable event IDs permit 1–64 letters, digits, `_` and `-`. Up to 64 configured definitions and 64 positive thresholds per event are supported; thresholds are bounded to 4096. -Membership uses the accepted pipeline's authoritative epoch-millisecond occurrence time and occurrence UUID. Standalone ingress assigns its timestamp during accepted processing; an external site's untrusted timestamp is not used. Current identified proxy deliveries carry a separate `canonicalOccurrenceTime` and original UUID, including delayed offline delivery. Ordinary event processing time, LastVotes, cooldowns, streaks, and post-vote `voteTime` retain their existing receiving-proxy clock semantics. The native vote and post-vote events expose the separate occurrence timestamp. A vote accepted during a window can count even when delivered after its end. Votes before start or at/after end do not count. Fake/test votes are always excluded. A matching occurrence counts once per player/event; overlapping events each count it once. Enabled-site validation remains part of the normal accepted-vote pipeline. An optional filter selects configured site keys. +Membership uses the accepted pipeline's authoritative epoch-millisecond occurrence time and occurrence UUID. Standalone ingress captures its timestamp and a fresh local occurrence UUID once at receipt, before executor admission; an external site's untrusted timestamp is not used. Overflow retries and local time-change cache recovery retain that UUID, so a crash after ledger publication but before queue acknowledgement cannot recount the receipt or unlock another date milestone. Distinct receipts receive distinct UUIDs even when their player, service and timestamp match. Current identified proxy deliveries carry a separate `canonicalOccurrenceTime` and original UUID, including delayed offline delivery. Ordinary event processing time, LastVotes, cooldowns, streaks, and post-vote `voteTime` retain their existing receiving-proxy clock semantics. The native vote and post-vote events expose the separate occurrence timestamp. `PlayerVoteEvent.localOccurrenceId` carries only local receipt identity; it does not populate `proxyVoteId` or imply proxy provenance. The existing post-vote `voteUUID` carries the effective occurrence UUID. A vote accepted during a window can count even when delivered after its end. Votes before start or at/after end do not count. Fake/test votes are always excluded. A matching occurrence counts once per player/event; overlapping events each count it once. Enabled-site validation remains part of the normal accepted-vote pipeline. An optional filter selects configured site keys. For a proxy network, **one backend owns each event ledger**. Set `AccountingServer` to that backend's exact configured server name. Use identified **all-server** delivery so that accounting backend receives every occurrence. Other backends do not increment the event, and their progress command directs players to the owner. Targeted delivery, legacy proxy deliveries missing original IDs/times, and local backend ingress on a proxy-configured server are intentionally excluded. They cannot reliably provide both complete event progress and network deduplication. This does not change their existing normal vote processing. A proxy event without an accounting owner is disabled. Moving the owner requires preserving the owner-local ledger and a new event ID; this feature does not migrate or replicate ledgers automatically. @@ -68,7 +68,7 @@ Canonical date accounting requires an explicit transport vote ID; a legacy total Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. -Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries); queue-file loading and persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key while preserving the established `Time` field and legacy records. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. +Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries); queue-file loading and persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. From 63d9a58a57933df29c046fefdc1a6f81612977cc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:01:56 -0600 Subject: [PATCH 14/22] Retain captured votes across cancelled executor admission --- .../votingplugin/VotingPluginMain.java | 15 +-- .../votingplugin/listeners/VotiferEvent.java | 83 ++++++++++++---- .../listeners/VotifierVoteOverflowQueue.java | 99 +++++++++++++++++-- .../DateVoteIngressOrderingTest.java | 7 +- .../DateVotifierStartupCaptureTest.java | 80 ++++++++++++++- .../VotiferEventDisabledVoteSiteTest.java | 26 ++++- .../VotifierVoteOverflowQueueTest.java | 52 ++++++++++ docs/date-vote-milestones.md | 6 +- 8 files changed, 324 insertions(+), 44 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 56063f0159..33a13f34c8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -2061,17 +2061,12 @@ private void registerCommands() { void registerEarlyVotifierIngress() { if (localVotifierIngressClosed || !isVotifierLoaded() || localVotifierListener != null) return; VotiferEvent listener = new VotiferEvent(this, () -> localVotifierIngressReady); + // Publish the memory owner before capture. Its independent worker loads + // disk state; vote-executor cancellation cannot discard queue setup. + votifierVoteOverflowQueue = VotifierVoteOverflowQueue.initializeAsync(this, + listener::processVote, () -> localVotifierIngressReady && !localVotifierIngressClosed); localVotifierListener = listener; - // Queue-file loading stays off the server owner. FIFO admission places this - // setup ahead of every captured vote, without a second startup collection. - getVoteTimer().submit(() -> { - VotifierVoteOverflowQueue queue = new VotifierVoteOverflowQueue(this, - (VotifierVoteOverflowQueue.OccurrenceProcessor) listener::processVote, - () -> localVotifierIngressReady && !localVotifierIngressClosed); - votifierVoteOverflowQueue = queue; - if (localVotifierIngressClosed) queue.close(); - else if (localVotifierIngressReady) queue.start(); - }); + if (localVotifierIngressReady) votifierVoteOverflowQueue.start(); getServer().getPluginManager().registerEvents(listener, this); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 3c849bdba4..511f666e4f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -2,6 +2,8 @@ import java.util.concurrent.RejectedExecutionException; import java.util.UUID; +import java.util.LinkedHashMap; +import java.util.Map; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -21,6 +23,20 @@ public class VotiferEvent implements Listener { private final VotingPluginMain plugin; private final java.util.function.BooleanSupplier ready; private volatile boolean accepting = true; + private static final int MAX_CAPTURED = 256; + private final Object captureLock = new Object(); + // Admission ownership only. Entries leave at processing start or confirmed + // overflow transfer; no totals, reward progress or second execution lane. + private final Map captured = new LinkedHashMap<>(); + private static final class CapturedVote { + final String username, site; + final long occurredAt; + final UUID id; + boolean transferring; + CapturedVote(String username, String site, long occurredAt, UUID id) { + this.username = username; this.site = site; this.occurredAt = occurredAt; this.id = id; + } + } /** * Instantiates a new votifer event. @@ -32,7 +48,43 @@ public class VotiferEvent implements Listener { public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier ready) { this.plugin = plugin; this.ready = ready; } - public void stop() { accepting = false; } + /** Retire admission and reconcile tasks before the vote executor is cancelled. */ + public void stop() { + synchronized (captureLock) { + accepting = false; + for (CapturedVote vote : java.util.List.copyOf(captured.values())) transferLocked(vote); + } + } + + public int getPendingCaptureCount() { + synchronized (captureLock) { return captured.size(); } + } + + private void runCaptured(CapturedVote vote) { + synchronized (captureLock) { + if (!captured.containsKey(vote.id) || vote.transferring) return; + if (!accepting || !ready.getAsBoolean()) { transferLocked(vote); return; } + // Once execution starts, the normal accepted pipeline owns effects. + captured.remove(vote.id); + } + processVote(vote.site, vote.username, vote.occurredAt, vote.id); + } + + private void transferLocked(CapturedVote vote) { + if (vote.transferring) return; + vote.transferring = true; + VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); + if (overflow == null) { transferred(vote, false); return; } + overflow.enqueueAfterInitialization(vote.username, vote.site, vote.occurredAt, vote.id, + accepted -> transferred(vote, accepted)); + } + + private void transferred(CapturedVote vote, boolean admitted) { + synchronized (captureLock) { captured.remove(vote.id); } + if (!admitted) plugin.getLogger().severe("Votifier vote queue unavailable or full; vote was not admitted for " + + MinecraftUsernameValidator.sanitizeForLog(vote.username)); + else plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry"); + } /** * Processes a validated vote. The overflow queue invokes this callback from @@ -153,23 +205,20 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("VoteSite: " + voteSite); plugin.debug("IP: " + IP); - try { - plugin.getVoteTimer().submit(() -> { - if (ready.getAsBoolean() && accepting) processVote(voteSite, voteUsername, occurredAt, localOccurrenceId); - else bufferVote(voteUsername, voteSite, occurredAt, localOccurrenceId); - }); - } catch (RejectedExecutionException rejected) { - bufferVote(voteUsername, voteSite, occurredAt, localOccurrenceId); + CapturedVote capturedVote = new CapturedVote(voteUsername, voteSite, occurredAt, localOccurrenceId); + synchronized (captureLock) { + if (!accepting) return; + if (captured.size() >= MAX_CAPTURED) { + plugin.getLogger().severe("Votifier capture capacity exhausted; vote was not admitted for " + + MinecraftUsernameValidator.sanitizeForLog(voteUsername)); + return; + } + captured.put(localOccurrenceId, capturedVote); } - } - - private void bufferVote(String username, String site, long occurredAt, UUID localOccurrenceId) { - VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); - if (overflow == null || !overflow.enqueue(username, site, occurredAt, localOccurrenceId)) { - plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " - + MinecraftUsernameValidator.sanitizeForLog(username)); - } else { - plugin.getLogger().warning("Vote pipeline unavailable or saturated; queued Votifier vote for retry"); + try { + plugin.getVoteTimer().submit(() -> runCaptured(capturedVote)); + } catch (RejectedExecutionException rejected) { + synchronized (captureLock) { transferLocked(capturedVote); } } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 1a0a2d2dd7..c58f3d5499 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -22,6 +22,7 @@ import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.bukkit.configuration.file.YamlConfiguration; @@ -59,6 +60,11 @@ public interface OccurrenceProcessor { void process(String serviceSite, String u private boolean persistenceDirty; private boolean started; private boolean closed; + private boolean closing; + private volatile boolean loaded; + private volatile boolean loadFailed; + private int pendingIngress; + private final boolean asynchronousInitialization; private long stateVersion; private long durableVersion; @@ -80,6 +86,18 @@ public VotifierVoteOverflowQueue(VotingPluginMain plugin, VoteProcessor processo public VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed) { + this(plugin, processor, processingAllowed, false); + } + + /** Memory-only construction; all loading and handoffs run on the existing queue worker. */ + public static VotifierVoteOverflowQueue initializeAsync(VotingPluginMain plugin, OccurrenceProcessor processor, + java.util.function.BooleanSupplier processingAllowed) { + return new VotifierVoteOverflowQueue(plugin, processor, processingAllowed, true); + } + + private VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor, + java.util.function.BooleanSupplier processingAllowed, boolean asynchronousInitialization) { + this.asynchronousInitialization = asynchronousInitialization; this.plugin = plugin; this.processor = processor; this.processingAllowed = processingAllowed; @@ -90,7 +108,8 @@ public VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor pr return thread; }); this.worker.setRemoveOnCancelPolicy(true); - load(); + if (asynchronousInitialization) worker.execute(this::load); + else load(); } /** @@ -123,9 +142,35 @@ public boolean enqueue(String username, String serviceSite, long occurredAt) { } public boolean enqueue(String username, String serviceSite, long occurredAt, UUID localOccurrenceId) { + return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, false); + } + + /** Transfer stays behind initial loading and is bounded independently of executor admissions. */ + public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + Consumer completion) { + boolean submitted = false; + synchronized (lock) { + if (!closed && !closing && pendingIngress < MAX_ENTRIES) { + pendingIngress++; + try { + worker.execute(() -> { + boolean accepted; + try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true); } + finally { synchronized (lock) { pendingIngress--; } } + completion.accept(accepted); + }); + submitted = true; + } catch (RejectedExecutionException rejected) { pendingIngress--; } + } + } + if (!submitted) completion.accept(false); + } + + private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + boolean admittedTransfer) { if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false; synchronized (lock) { - if (closed || entries.size() >= MAX_ENTRIES) return false; + if (closed || closing && !admittedTransfer || !loaded || loadFailed || entries.size() >= MAX_ENTRIES) return false; entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId)); stateVersion++; requestPersistenceLocked(); @@ -134,6 +179,9 @@ public boolean enqueue(String username, String serviceSite, long occurredAt, UUI } } + /** Completion only; transfers separately report load/capacity admission failure. */ + public boolean isInitializationComplete() { return loaded; } + /** * Returns the number of votes waiting for admission or completion. * @@ -152,7 +200,7 @@ private void scheduleDrain() { } private void scheduleDrainLocked() { - if (closed || !started || drainScheduled) return; + if (closed || closing || !loaded || loadFailed || !started || drainScheduled) return; drainScheduled = true; try { worker.schedule(this::drain, 0, TimeUnit.MILLISECONDS); @@ -164,6 +212,7 @@ private void scheduleDrainLocked() { private void drain() { while (true) { synchronized (lock) { + if (closed || closing || !loaded || loadFailed) { drainScheduled = false; return; } if (durableVersion != stateVersion) { drainScheduled = false; return; @@ -269,18 +318,16 @@ private void persistLoop() { } private void load() { + ArrayDeque recovered = new ArrayDeque<>(); try { if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return; YamlConfiguration yaml = new YamlConfiguration(); yaml.loadFromString(readQueueFile()); Object raw = yaml.get("Votes"); - if (!(raw instanceof List values)) return; + if (!(raw instanceof List values)) throw new IOException("queue votes are not a list"); + if (values.size() > MAX_ENTRIES) throw new IOException("queue exceeds entry capacity"); boolean skipped = false; for (Object value : values) { - if (entries.size() >= MAX_ENTRIES) { - skipped = true; - break; - } if (!(value instanceof Map map)) { skipped = true; continue; @@ -310,8 +357,9 @@ private void load() { continue; // Explicit corrupt IDs cannot become fresh occurrences. } } - entries.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId)); + recovered.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId)); } + synchronized (lock) { entries.addAll(recovered); } if (skipped) { synchronized (lock) { stateVersion++; @@ -319,8 +367,12 @@ private void load() { } } } catch (Exception failure) { + loadFailed = true; plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName()); - } + } finally { + loaded = true; + synchronized (lock) { scheduleDrainLocked(); } + } } private String readQueueFile() throws IOException { @@ -376,6 +428,7 @@ private void writeSnapshotLocked(List snapshot) throws IOException @Override public void close() { + if (asynchronousInitialization) { closeInitializedWorker(); return; } List snapshot; synchronized (lock) { if (closed) return; @@ -391,6 +444,7 @@ public void close() { } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + if (loadFailed) return; try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); @@ -401,6 +455,31 @@ public void close() { } } + private void closeInitializedWorker() { + synchronized (lock) { + if (closed || closing) return; + closing = true; + // FIFO finalization follows loading and every accepted ingress transfer. + // Never replace an unreadable disk queue with a guessed empty snapshot. + worker.execute(() -> { + List snapshot; + synchronized (lock) { closed = true; snapshot = new ArrayList<>(entries); } + if (loadFailed) return; + try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } } + catch (IOException failure) { + plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: " + + failure.getClass().getSimpleName()); + } + }); + worker.shutdown(); + } + try { + if (!worker.awaitTermination(1, TimeUnit.SECONDS)) { + plugin.getLogger().warning("Votifier queue shutdown publication is still pending on its worker"); + } + } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } + private static final class PendingVote { private final String username; private final String serviceSite; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java index 37a947374a..66a7f4799f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVoteIngressOrderingTest.java @@ -70,8 +70,11 @@ private static void ready(VotingPluginMain plugin) throws Exception { assertEquals(1, registered.size()); assertInstanceOf(com.bencodez.votingplugin.listeners.VotiferEvent.class, registered.getFirst()); verify(plugin.getDateVoteMilestones(), never()).reload(); - var setup = org.mockito.ArgumentCaptor.forClass(Runnable.class); - verify(timer).submit(setup.capture()); setup.getValue().run(); + assertEquals(1, queues.constructed().size(), "capture publishes its independent queue owner immediately"); + var queueField = VotingPluginMain.class.getDeclaredField("votifierVoteOverflowQueue"); + queueField.setAccessible(true); + assertSame(queues.constructed().getFirst(), queueField.get(plugin)); + verifyNoInteractions(timer); // Loading cannot be lost with vote-executor cancellation. verify(queues.constructed().getFirst(), never()).start(); ready(plugin); Runnable proxy = () -> { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java index fb6001e8b9..0329385ff8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -89,7 +89,15 @@ private final class Fixture implements AutoCloseable { } VotiferEvent capture() { return registered.stream().filter(VotiferEvent.class::isInstance).map(VotiferEvent.class::cast).findFirst().orElseThrow(); } void vote() { capture().onVotiferEvent(new com.vexsoftware.votifier.model.VotifierEvent(new Vote(SERVICE, "Steve", "127.0.0.1", "1"))); } - void drain() throws Exception { timer.submit(() -> { }).get(3, TimeUnit.SECONDS); } + void drain() throws Exception { + timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + while ((!plugin.getVotifierVoteOverflowQueue().isInitializationComplete() || capture().getPendingCaptureCount() != 0) + && System.nanoTime() < deadline) Thread.sleep(5); + assertTrue(plugin.getVotifierVoteOverflowQueue().isInitializationComplete(), "queue initialization did not finish"); + assertEquals(0, capture().getPendingCaptureCount(), "capture transfers did not finish"); + timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + } void open() throws Exception { ready(plugin); plugin.initializeDateVoteIngress(() -> fail("Standalone does not open proxy ingress")); } void received() throws Exception { assertTrue(delivered.await(3, TimeUnit.SECONDS)); drain(); } DateVoteLedger.Progress progress() throws Exception { @@ -216,6 +224,76 @@ public void close() { } } + @Test void blockedExecutorCannotLoseEarlyCaptureOrQueueSetupOnCancellation() throws Exception { + capturedAdmissionSurvivesCancellation(false); + } + + @Test void blockedExecutorCannotLoseReadyCaptureOnCancellation() throws Exception { + capturedAdmissionSurvivesCancellation(true); + } + + private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Exception { + UUID playerId = UUID.randomUUID(); + UUID original; + long originalTime; + try (var fixture = new Fixture(root, 0, playerId)) { + if (readyFirst) { fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.open(); } + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + fixture.timer.submit(() -> { + entered.countDown(); + boolean interrupted = false; + try { + while (release.getCount() != 0) { + try { assertTrue(release.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException expected) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + // In the early case, even registration/setup happens behind the held + // vote worker. Queue construction and disk load must be independent. + if (!readyFirst) fixture.plugin.registerEarlyVotifierIngress(); + assertNotNull(fixture.plugin.getVotifierVoteOverflowQueue()); + long before = System.currentTimeMillis(); fixture.vote(); long after = System.currentTimeMillis(); + assertEquals(1, fixture.capture().getPendingCaptureCount()); + assertTrue(fixture.accepted.isEmpty()); + var registryField = VotiferEvent.class.getDeclaredField("captured"); registryField.setAccessible(true); + var registry = (Map) registryField.get(fixture.capture()); + original = (UUID) registry.keySet().iterator().next(); + var captureTime = registry.values().iterator().next().getClass().getDeclaredField("occurredAt"); captureTime.setAccessible(true); + originalTime = captureTime.getLong(registry.values().iterator().next()); + try { + fixture.capture().stop(); + assertFalse(fixture.timer.shutdownNow().isEmpty(), "the capture callback never executed"); + } finally { release.countDown(); } + assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS)); + fixture.plugin.getVotifierVoteOverflowQueue().close(); + assertEquals(0, fixture.capture().getPendingCaptureCount()); + var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(1, disk.size()); + assertEquals(original, UUID.fromString((String) disk.getFirst().get("LocalOccurrenceId"))); + assertEquals(originalTime, ((Number) disk.getFirst().get("Time")).longValue()); + assertTrue(originalTime >= before && originalTime <= after); + verify(fixture.user, never()).addTotal(); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + try (var restarted = new Fixture(root, 1, playerId)) { + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); restarted.open(); restarted.received(); + assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(original, restarted.posted.getFirst().getVoteUUID()); + assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, restarted.accepted.getFirst().getTime()); + assertEquals(1, restarted.progress().votes()); + verify(restarted.user).addTotal(); verify(restarted.user).setTime(restarted.site); + verify(restarted.plugin.getRewardHandler(), times(1)).giveReward(eq(restarted.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } + try (var secondRestart = new Fixture(root, 0, playerId)) { + secondRestart.plugin.registerEarlyVotifierIngress(); secondRestart.drain(); secondRestart.open(); secondRestart.drain(); + assertTrue(secondRestart.accepted.isEmpty()); assertEquals(1, secondRestart.progress().votes()); + } + } + @Test void retiredCaptureCannotReopenAnOldStartupGeneration() throws Exception { try (var fixture = new Fixture(root, 0)) { fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index b0c074f551..e9f8578521 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -144,15 +144,37 @@ public void testUnknownSiteStillAttemptsGenerationByVotifierPath() { verify(pluginManager).callEvent(any(PlayerVoteEvent.class)); } + @Test + public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { + var tasks = new java.util.ArrayList(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return CompletableFuture.completedFuture(null); }) + .when(voteTimer).submit(any(Runnable.class)); + doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(4)).accept(true); return null; }) + .when(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); + for (int index = 0; index < 257; index++) listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); + org.junit.jupiter.api.Assertions.assertEquals(256, listener.getPendingCaptureCount()); + org.junit.jupiter.api.Assertions.assertEquals(256, tasks.size()); + listener.stop(); listener.stop(); + org.junit.jupiter.api.Assertions.assertEquals(0, listener.getPendingCaptureCount()); + // Cancelled callbacks that later run cannot transfer or process again. + tasks.forEach(Runnable::run); + verify(overflowQueue, org.mockito.Mockito.times(256)).enqueueAfterInitialization(anyString(), anyString(), + org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); + verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); + } + @Test public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - when(overflowQueue.enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class))).thenReturn(true); + doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(4)).accept(true); return null; }) + .when(overflowQueue).enqueueAfterInitialization(org.mockito.ArgumentMatchers.eq("Steve"), + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); - verify(overflowQueue).enqueue(org.mockito.ArgumentMatchers.eq("Steve"), org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class)); + verify(overflowQueue).enqueueAfterInitialization(org.mockito.ArgumentMatchers.eq("Steve"), + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 881d093ccc..27f5c14d71 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -296,6 +296,58 @@ void legacyRowsReceiveDistinctDurableIdsBeforeDrainAndMalformedIdsFailClosed(@Te } finally { queue.close(); voteTimer.shutdownNow(); } } + @Test + void asyncLoadingPreservesFullRecoveredQueueAndRejectsNewTransfers(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + try (var seed = new VotifierVoteOverflowQueue(plugin, (site, user) -> { })) { + for (int index = 0; index < 256; index++) assertTrue(seed.enqueue("Steve", "example.org", 123L)); + org.junit.jupiter.api.Assertions.assertFalse(seed.enqueue("Steve", "example.org", 123L)); + } + var before = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(dataFolder.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + var transferred = new java.util.concurrent.CompletableFuture(); + try (var resumed = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { + resumed.enqueueAfterInitialization("Alex", "new.example.org", 456L, java.util.UUID.randomUUID(), transferred::complete); + org.junit.jupiter.api.Assertions.assertFalse(transferred.get(3, TimeUnit.SECONDS)); + assertEquals(256, resumed.size()); + } + var after = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration(dataFolder.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(before, after, "recovered identities must not be overwritten or truncated"); + } + + @Test + void asyncUnreadableStoreRejectsTransferAndClosePreservesOriginalFile(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + String corrupt = "Votes: {not: a-list}\n"; Files.writeString(file, corrupt); + var transferred = new java.util.concurrent.CompletableFuture(); + try (var queue = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { + queue.enqueueAfterInitialization("Steve", "example.org", 456L, java.util.UUID.randomUUID(), transferred::complete); + org.junit.jupiter.api.Assertions.assertFalse(transferred.get(3, TimeUnit.SECONDS)); + } + assertEquals(corrupt, Files.readString(file)); + } + + @Test + void overCapacityRecoveredStoreFailsClosedWithoutTruncation(@TempDir Path dataFolder) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + Path file = dataFolder.resolve("VotifierVoteQueue.yml"); + String original = "Votes:\n" + ("- {Username: Steve, ServiceSite: example.org, Time: 123}\n").repeat(257); + Files.writeString(file, original); + var transferred = new java.util.concurrent.CompletableFuture(); + try (var queue = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { + queue.enqueueAfterInitialization("Alex", "new.example.org", 456L, java.util.UUID.randomUUID(), transferred::complete); + org.junit.jupiter.api.Assertions.assertFalse(transferred.get(3, TimeUnit.SECONDS)); + assertEquals(0, queue.size(), "an over-capacity file cannot be partially admitted"); + } + assertEquals(original, Files.readString(file)); + } + private static void waitForFile(Path file) throws Exception { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); while (!Files.exists(file) && System.nanoTime() < deadline) { diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 8cbcabee9a..00b88ba06e 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -1,6 +1,6 @@ # DateVoteMilestones -Owners define named voting events in `SpecialRewards.yml`. There are no built-in holidays, recurring rules, additional vote totals, or alternate reward engines. Existing lifetime/monthly totals and normal rewards continue unchanged. The default empty section enables nothing. +Owners define named voting events in `SpecialRewards.yml`. There are no built-in holidays, recurring rules, additional vote totals, or alternate reward engines. Existing lifetime/monthly totals and normal rewards continue unchanged. The shipped example event is explicitly `Enabled: false` and enables no date rewards. Configure its dates and rewards before opting in. Complete opt-in example: @@ -68,8 +68,10 @@ Canonical date accounting requires an explicit transport vote ID; a legacy total Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. -Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries); queue-file loading and persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. +Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries). A bounded 256-entry ingress registry owns validated receipts before executor submission and releases each only when normal processing starts or overflow confirms transfer. Retirement transfers callbacks that have not started before vote-executor cancellation, preserving the receipt UUID/time. The overflow is published as a memory owner before listener registration; its independent worker loads recovered rows before accepting transfers, so cancelling the vote executor cannot cancel queue setup or overwrite recovered receipts. Queue-file loading and ordinary persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. A full recovered queue, an over-capacity persisted file or an unreadable queue explicitly rejects new transfers without evicting recovered votes or replacing the rejected file. Initialization and queued transfers remain bounded; they are not additional reward or accounting engines. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. Extension events dispatched from a Bukkit/Paper/Folia gameplay thread are admitted to the existing ordered storage worker (at most 64 pending). The asynchronous event flag is not treated as a thread check. Deferred proxy accounting remains unconfirmed/quarantined until reconciliation; submission alone never acknowledges durable accounting. Reload, disable, or overload can withhold such extension occurrences. Native VotingPlugin asynchronous ingress retains synchronous completion reporting. + +Native queue retirement drains its accepted worker transfers and writes the final snapshot after loading, with a one-second lifecycle wait. A blocked worker or failed write is reported as incomplete publication, not a successful durable admission; sudden process death before asynchronous publication can still lose an unpublished receipt. This does not claim universal exactly-once normal rewards or uninterrupted receipt while bounded owners are full. From d6d6dbc81dc26f74cac01d98ce0713b2deec5f8d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:01:56 -0600 Subject: [PATCH 15/22] Ship an explicitly disabled date milestone example --- .../src/main/resources/SpecialRewards.yml | 34 ++++++++++++++++--- .../DateVoteMilestonesTest.java | 26 ++++++++++++++ 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/resources/SpecialRewards.yml b/VotingPlugin/src/main/resources/SpecialRewards.yml index 0fa7017da6..554d483630 100644 --- a/VotingPlugin/src/main/resources/SpecialRewards.yml +++ b/VotingPlugin/src/main/resources/SpecialRewards.yml @@ -398,8 +398,32 @@ DailyAwards: Messages: Player: '&aYou came in second place in %TopVoter%!' -# Owner-defined dated events. Opt-in; use a NEW event ID when changing accounting. -# Explicit local ISO start/end + IANA timezone. Start inclusive, end exclusive. -# Proxy use requires one AccountingServer and identified all-server delivery. -# Full configuration example and durability limits: docs/date-vote-milestones.md -DateVoteMilestones: {} +# Owner-defined dated events. This example is DISABLED by default. +# Set your own dates/rewards, then enable the event. No automatic recurring reset. +# Keep the event ID stable to retain progress; never delete its ledger and reuse it. +# After accounting starts, use a NEW ID for changes to dates, timezone, thresholds, +# VoteSites or AccountingServer. Unsafe edits are rejected, preserving progress. +# DisplayName and Enabled may change without resetting counts. +# Full configuration and durability limits: docs/date-vote-milestones.md +DateVoteMilestones: + example_event: + Enabled: false + DisplayName: 'Example voting event' + # Local ISO date-times interpreted in the explicit IANA timezone below. + Start: '2026-10-01T00:00:00' # Inclusive: occurrences at this instant count. + End: '2026-11-01T00:00:00' # Exclusive: occurrences at this instant do not count. + Timezone: 'UTC' + # Standalone: leave empty. Proxy: choose exactly ONE backend Server name, + # identically on every backend, and use identified all-server delivery. + AccountingServer: '' + # Configured VoteSites.yml keys (not service names); empty means all sites. + VoteSites: [] + Milestones: + '10': + Rewards: + Messages: + Player: '&aYou reached 10 votes in %DateVoteEvent%!' + '25': + Rewards: + Messages: + Player: '&aYou reached 25 votes in %DateVoteEvent%!' diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java index d52741d356..79f36269ed 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/specialrewards/datemilestones/DateVoteMilestonesTest.java @@ -32,6 +32,32 @@ private DateVoteMilestones manager() { when(plugin.getOptions().isProcessRewards()).thenReturn(true); var m = new DateVoteMilestones(plugin); m.reload(); return m; } + @Test void bundledDefaultExampleParsesAndNeverEnablesDateRewards() throws Exception { + var defaults = new YamlConfiguration(); + try (var resource = DateVoteMilestonesTest.class.getResourceAsStream("/SpecialRewards.yml")) { + assertNotNull(resource, "the shipped default resource must be tested"); + defaults.load(new java.io.InputStreamReader(resource, java.nio.charset.StandardCharsets.UTF_8)); + } + var events = defaults.getConfigurationSection("DateVoteMilestones"); + assertNotNull(events); assertEquals(java.util.Set.of("example_event"), events.getKeys(false)); + var definition = DateVoteMilestones.parse("example_event", events.getConfigurationSection("example_event")); + assertFalse(definition.enabled()); assertEquals("UTC", definition.timezone()); + assertEquals(java.util.List.of(10, 25), definition.thresholds()); + assertEquals(java.time.Instant.parse("2026-10-01T00:00:00Z").toEpochMilli(), definition.start()); + assertEquals(java.time.Instant.parse("2026-11-01T00:00:00Z").toEpochMilli(), definition.end()); + assertTrue(definition.sites().isEmpty()); assertEquals("", definition.accountingServer()); + assertFalse(definition.matches(definition.start(), "a", true, false, "owner")); + assertFalse(definition.matches(time, "a", true, false, "owner")); + config.loadFromString(defaults.saveToString()); + var manager = manager(); + for (int index = 0; index < 25; index++) + assertTrue(manager.accepted(user, "a", UUID.randomUUID(), time, true, false, false, false, false)); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + assertFalse(Files.exists(root.resolve("date-vote-milestones")), "disabled defaults must not create accounting state"); + assertTrue(config.getBoolean("EnableMonthlyAwards"), "existing default awards stay intact"); + assertFalse(config.getBoolean("EnableWeeklyAwards")); + } + @Test void failedOccurrencePublicationIsReportedWithoutCountingOrRepeatingAnAward() throws Exception { event("october", "owner"); var manager = manager(); assertTrue(manager.accepted(user, "a", UUID.randomUUID(), time, true, true, true, false, false)); From b4aa30d28337d2b6e22d8fef2ca911791a303ab3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:21:41 -0600 Subject: [PATCH 16/22] Retain started Votifier receipts through shutdown cancellation --- AGENTS.md | 1 + .../votingplugin/listeners/VotiferEvent.java | 48 +++- .../listeners/VotifierVoteOverflowQueue.java | 40 +++- .../DateVotifierStartupCaptureTest.java | 45 ++++ .../VotiferEventDisabledVoteSiteTest.java | 12 +- .../VotifierStartedCaptureOwnershipTest.java | 218 ++++++++++++++++++ docs/date-vote-milestones.md | 4 +- 7 files changed, 349 insertions(+), 19 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java diff --git a/AGENTS.md b/AGENTS.md index 5654f1981b..449175c380 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -324,6 +324,7 @@ Control behavior in production. - Trace whether the code runs on the connector worker, proxy thread, Bukkit primary thread, or a SQL executor. - Preserve queued votes across saturation, reload, runtime replacement, scheduler rejection/cancellation, shutdown, and restart; overflow handling must be bounded, durable when promised, and observable rather than silently dropping work. Scheduler admission is never proof of durable ownership. +- Local Votifier captures remain owned after STARTED until normal callback completion or confirmed overflow transfer. Transfer execution stays fenced while the original callback runs; successful non-interrupted completion reconciles only its exact occurrence, while interruption/exception or failed transfer retains bounded recovery ownership. A callback completing after the final overflow snapshot is an explicit uncertain reconciliation case; a retired queue must not overwrite a successor, and ordinary partial external effects retain the existing at-least-once boundary. - Proxy-to-backend guaranteed delivery is capability negotiated and at least once. Journal a reward-bearing envelope before reporting transport acceptance, retain it until the matching backend completion acknowledgement is durable, persist completed IDs before acknowledgement for restart-safe deduplication, retire receipts only through the durable diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 511f666e4f..b721fa7613 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -25,14 +25,16 @@ public class VotiferEvent implements Listener { private volatile boolean accepting = true; private static final int MAX_CAPTURED = 256; private final Object captureLock = new Object(); - // Admission ownership only. Entries leave at processing start or confirmed - // overflow transfer; no totals, reward progress or second execution lane. + // Receipt ownership only. Entries leave at completed processing or confirmed + // overflow transfer; STARTED work remains owned until then. private final Map captured = new LinkedHashMap<>(); private static final class CapturedVote { final String username, site; final long occurredAt; final UUID id; boolean transferring; + volatile boolean started, completed, finished, transferred; + VotifierVoteOverflowQueue recoveryOwner; CapturedVote(String username, String site, long occurredAt, UUID id) { this.username = username; this.site = site; this.occurredAt = occurredAt; this.id = id; } @@ -62,25 +64,47 @@ public int getPendingCaptureCount() { private void runCaptured(CapturedVote vote) { synchronized (captureLock) { - if (!captured.containsKey(vote.id) || vote.transferring) return; + if (!captured.containsKey(vote.id) || vote.transferring || vote.started) return; if (!accepting || !ready.getAsBoolean()) { transferLocked(vote); return; } - // Once execution starts, the normal accepted pipeline owns effects. - captured.remove(vote.id); + vote.started = true; + } + boolean completed = false; + try { completed = processCapturedVote(vote.site, vote.username, vote.occurredAt, vote.id) + && !Thread.currentThread().isInterrupted(); } + finally { + VotifierVoteOverflowQueue acknowledge = null; + synchronized (captureLock) { + vote.completed = completed; + vote.finished = true; + if (completed) { + captured.remove(vote.id); + if (vote.transferred) acknowledge = vote.recoveryOwner; + } + } + if (acknowledge != null) acknowledge.acknowledgeOccurrence(vote.id); + else if (vote.transferred && vote.recoveryOwner != null) vote.recoveryOwner.resumeTransfers(); } - processVote(vote.site, vote.username, vote.occurredAt, vote.id); } private void transferLocked(CapturedVote vote) { if (vote.transferring) return; vote.transferring = true; VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); + vote.recoveryOwner = overflow; if (overflow == null) { transferred(vote, false); return; } overflow.enqueueAfterInitialization(vote.username, vote.site, vote.occurredAt, vote.id, + () -> !vote.started || vote.finished && !vote.completed, accepted -> transferred(vote, accepted)); } private void transferred(CapturedVote vote, boolean admitted) { - synchronized (captureLock) { captured.remove(vote.id); } + boolean completed; + synchronized (captureLock) { + vote.transferred = admitted; completed = vote.completed; + if (admitted || completed) captured.remove(vote.id); + } + if (admitted && completed) vote.recoveryOwner.acknowledgeOccurrence(vote.id); + if (completed) return; // Current normal processing won; do not leave an ordinary duplicate retry. if (!admitted) plugin.getLogger().severe("Votifier vote queue unavailable or full; vote was not admitted for " + MinecraftUsernameValidator.sanitizeForLog(vote.username)); else plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry"); @@ -100,6 +124,10 @@ public void processVote(String voteSite, String voteUsername, long occurredAt) { } public void processVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { + processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId); + } + + private boolean processCapturedVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId"); try { plugin.getServerData().addServiceSite(voteSite); @@ -111,7 +139,7 @@ public void processVote(String voteSite, String voteUsername, long occurredAt, U || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) { plugin.getLogger().severe( "Ignoring vote from votifier since a proxy vote transport is enabled; receive votes on the proxy or enable VotifierBypass, then check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); - return; + return true; } String matchSite = ""; @@ -143,7 +171,7 @@ public void processVote(String voteSite, String voteUsername, long occurredAt, U && plugin.getConfigFile().isQueueVotesDuringTimeChange()) { plugin.debug("Adding vote to time queue " + voteUsername + "/" + voteSite); plugin.getTimeQueueHandler().addVote(voteUsername, voteSite, occurredAt, localOccurrenceId); - return; + return true; } String voteSiteName = plugin.getVoteSiteManager().getVoteSiteName(true, serviceSite, matchSite); @@ -157,9 +185,11 @@ public void processVote(String voteSite, String voteUsername, long occurredAt, U if (voteEvent.isCancelled()) { plugin.debug("Vote cancelled"); } + return true; } catch (Exception e) { plugin.getLogger().severe("Error occured during vote processing"); e.printStackTrace(); + return false; // An exception does not prove ordinary accounting/rewards completed. } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index c58f3d5499..32878f6257 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -148,6 +148,12 @@ public boolean enqueue(String username, String serviceSite, long occurredAt, UUI /** Transfer stays behind initial loading and is bounded independently of executor admissions. */ public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, Consumer completion) { + enqueueAfterInitialization(username, serviceSite, occurredAt, localOccurrenceId, () -> true, completion); + } + + /** A STARTED original callback retains execution ownership until its outcome is known. */ + public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + java.util.function.BooleanSupplier executionReleased, Consumer completion) { boolean submitted = false; synchronized (lock) { if (!closed && !closing && pendingIngress < MAX_ENTRIES) { @@ -155,7 +161,7 @@ public void enqueueAfterInitialization(String username, String serviceSite, long try { worker.execute(() -> { boolean accepted; - try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true); } + try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true, executionReleased); } finally { synchronized (lock) { pendingIngress--; } } completion.accept(accepted); }); @@ -168,10 +174,15 @@ public void enqueueAfterInitialization(String username, String serviceSite, long private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, boolean admittedTransfer) { + return enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, admittedTransfer, () -> true); + } + + private boolean enqueueStored(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, + boolean admittedTransfer, java.util.function.BooleanSupplier executionReleased) { if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false; synchronized (lock) { if (closed || closing && !admittedTransfer || !loaded || loadFailed || entries.size() >= MAX_ENTRIES) return false; - entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId)); + entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased)); stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -254,7 +265,7 @@ private void drain() { private PendingVote nextUnsubmittedLocked() { for (PendingVote pending : entries) { - if (!pending.submitted) return pending; + if (!pending.submitted && pending.executionReleased.getAsBoolean()) return pending; } return null; } @@ -268,6 +279,22 @@ private void acknowledge(PendingVote pending) { } } + /** Reconcile a receipt whose original normal callback completed during transfer. */ + public void acknowledgeOccurrence(UUID localOccurrenceId) { + synchronized (lock) { + if (closed) { + // A nonterminating callback outlived the final snapshot. A retired + // writer cannot overwrite a successor's queue; retain uncertainty. + plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation"); + return; + } + if (!entries.removeIf(pending -> pending.localOccurrenceId.equals(localOccurrenceId))) return; + stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); + } + } + /** Wake the same bounded drain when an original transferred callback finishes uncertainly. */ + public void resumeTransfers() { scheduleDrain(); } + private void requestPersistenceLocked() { persistenceDirty = true; if (persistenceScheduled) return; @@ -485,13 +512,20 @@ private static final class PendingVote { private final String serviceSite; private final long time; private final UUID localOccurrenceId; + private final java.util.function.BooleanSupplier executionReleased; private boolean submitted; private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId) { + this(username, serviceSite, time, localOccurrenceId, () -> true); + } + + private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId, + java.util.function.BooleanSupplier executionReleased) { this.username = username; this.serviceSite = serviceSite; this.time = time; this.localOccurrenceId = localOccurrenceId; + this.executionReleased = java.util.Objects.requireNonNull(executionReleased); } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java index 0329385ff8..75524c8060 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -306,6 +306,51 @@ private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Ex } } + @Test void startedNativeCallbackBlockedBeforeConsumerSurvivesShutdownAndRewardsOnceAfterRestart() throws Exception { + UUID playerId = UUID.randomUUID(), original; long originalTime; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + try (var fixture = new Fixture(root, 0, playerId)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.open(); + var serverData = fixture.plugin.getServerData(); + doAnswer(call -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test storage timeout"); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new IllegalStateException("native receipt storage interrupted before consumer", interrupted); + } + return null; + }).when(serverData).addServiceSite(SERVICE); + fixture.vote(); assertTrue(entered.await(3, TimeUnit.SECONDS)); + assertEquals(1, fixture.capture().getPendingCaptureCount()); assertTrue(fixture.accepted.isEmpty()); + var registryField = VotiferEvent.class.getDeclaredField("captured"); registryField.setAccessible(true); + var registry = (Map) registryField.get(fixture.capture()); original = (UUID) registry.keySet().iterator().next(); + var captureTime = registry.values().iterator().next().getClass().getDeclaredField("occurredAt"); captureTime.setAccessible(true); + originalTime = captureTime.getLong(registry.values().iterator().next()); + // Use the native teardown fence, then the real listener/worker stop order. + var closed = VotingPluginMain.class.getDeclaredField("localVotifierIngressClosed"); closed.setAccessible(true); closed.setBoolean(fixture.plugin, true); + var readiness = VotingPluginMain.class.getDeclaredField("localVotifierIngressReady"); readiness.setAccessible(true); readiness.setBoolean(fixture.plugin, false); + fixture.capture().stop(); fixture.timer.shutdownNow(); assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS)); + fixture.plugin.getVotifierVoteOverflowQueue().close(); assertEquals(0, fixture.capture().getPendingCaptureCount()); + var disk = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(1, disk.size()); assertEquals(original.toString(), disk.getFirst().get("LocalOccurrenceId")); + assertEquals(originalTime, ((Number) disk.getFirst().get("Time")).longValue()); + verify(fixture.user, never()).cache(); verify(fixture.user, never()).addTotal(); + verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(fixture.plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } finally { release.countDown(); } + try (var restarted = new Fixture(root, 1, playerId)) { + restarted.plugin.registerEarlyVotifierIngress(); restarted.drain(); restarted.open(); restarted.received(); + assertEquals(original, restarted.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(originalTime, restarted.accepted.getFirst().getCanonicalOccurrenceTime()); assertEquals(1, restarted.progress().votes()); + verify(restarted.user).addTotal(); verify(restarted.user).playerVote(restarted.site, true, false); + verify(restarted.plugin.getRewardHandler(), times(1)).giveReward(eq(restarted.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + } + try (var again = new Fixture(root, 0, playerId)) { + again.plugin.registerEarlyVotifierIngress(); again.drain(); again.open(); again.drain(); + assertTrue(again.accepted.isEmpty()); assertEquals(1, again.progress().votes()); verify(again.user, never()).addTotal(); + } + } + @Test void admittedOverflowCallbackCannotRewardAfterStartupGenerationRetires() throws Exception { try (var fixture = new Fixture(root, 0)) { fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index e9f8578521..75b6203a37 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -149,8 +149,8 @@ public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { var tasks = new java.util.ArrayList(); doAnswer(call -> { tasks.add(call.getArgument(0)); return CompletableFuture.completedFuture(null); }) .when(voteTimer).submit(any(Runnable.class)); - doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(4)).accept(true); return null; }) - .when(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); + doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(5)).accept(true); return null; }) + .when(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); for (int index = 0; index < 257; index++) listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); org.junit.jupiter.api.Assertions.assertEquals(256, listener.getPendingCaptureCount()); org.junit.jupiter.api.Assertions.assertEquals(256, tasks.size()); @@ -159,7 +159,7 @@ public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { // Cancelled callbacks that later run cannot transfer or process again. tasks.forEach(Runnable::run); verify(overflowQueue, org.mockito.Mockito.times(256)).enqueueAfterInitialization(anyString(), anyString(), - org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); + org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } @@ -167,14 +167,14 @@ public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { doThrow(new RejectedExecutionException("capacity exhausted")) .when(voteTimer).submit(any(Runnable.class)); - doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(4)).accept(true); return null; }) + doAnswer(call -> { ((java.util.function.Consumer) call.getArgument(5)).accept(true); return null; }) .when(overflowQueue).enqueueAfterInitialization(org.mockito.ArgumentMatchers.eq("Steve"), - org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); verify(overflowQueue).enqueueAfterInitialization(org.mockito.ArgumentMatchers.eq("Steve"), - org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any()); + org.mockito.ArgumentMatchers.eq(SERVICE_SITE), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java new file mode 100644 index 0000000000..8793ab7440 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java @@ -0,0 +1,218 @@ +package com.bencodez.votingplugin.tests.listeners; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; +import java.util.logging.Logger; +import org.bukkit.plugin.PluginManager; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.listeners.VotiferEvent; +import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; +import com.vexsoftware.votifier.model.Vote; + +/** Real ingress callback, real executor cancellation, real overflow publication/restart. */ +class VotifierStartedCaptureOwnershipTest { + @TempDir Path directory; + private VotingPluginMain plugin; + private PluginManager events; + private com.bencodez.votingplugin.data.ServerData serverData; + private ScheduledExecutorService timer; + private VotiferEvent listener; + private VotifierVoteOverflowQueue overflow; + private final AtomicBoolean ready = new AtomicBoolean(true); + private final List processed = new CopyOnWriteArrayList<>(); + private static final String SITE = "example.org"; + private static ScheduledExecutorService timer() { + return Executors.newSingleThreadScheduledExecutor(task -> { + var thread = new Thread(task, "test-native-vote-callback"); thread.setDaemon(true); return thread; + }); + } + @BeforeEach void setup() throws Exception { + plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(false); + when(plugin.getVoteSiteManager().getVoteSiteName(anyBoolean(), anyString(), anyString())).thenAnswer(call -> call.getArgument(1)); + when(plugin.getVoteSiteManager().hasVoteSite(anyString())).thenReturn(true); + events = mock(PluginManager.class); when(plugin.getServer().getPluginManager()).thenReturn(events); + doAnswer(call -> { processed.add(call.getArgument(0)); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + serverData = plugin.getServerData(); + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + listener = new VotiferEvent(plugin, ready::get); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + } + @AfterEach void close() { timer.shutdownNow(); overflow.close(); } + private com.vexsoftware.votifier.model.VotifierEvent receipt() { + var vote = mock(Vote.class); when(vote.getServiceName()).thenReturn(SITE); + when(vote.getUsername()).thenReturn("Steve"); when(vote.getAddress()).thenReturn("127.0.0.1"); + var event = mock(com.vexsoftware.votifier.model.VotifierEvent.class); when(event.getVote()).thenReturn(vote); return event; + } + private static ScheduledThreadPoolExecutor worker(VotifierVoteOverflowQueue queue) throws Exception { + var field = VotifierVoteOverflowQueue.class.getDeclaredField("worker"); field.setAccessible(true); + return (ScheduledThreadPoolExecutor) field.get(queue); + } + private static void workerTurn(VotifierVoteOverflowQueue queue) throws Exception { worker(queue).submit(() -> {}).get(5, TimeUnit.SECONDS); } + private void processingTurn() throws Exception { timer.submit(() -> {}).get(5, TimeUnit.SECONDS); } + private List> disk() { return YamlConfiguration.loadConfiguration(directory.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); } + @Test void startedBlockedBeforeAcceptedPipelineSurvivesStopCancellationAndRestart() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new IllegalStateException("native storage interrupted before vote consumer", interrupted); + } + return null; + }).when(serverData).addServiceSite(SITE); + long before = System.currentTimeMillis(); listener.onVotiferEvent(receipt()); long after = System.currentTimeMillis(); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); assertEquals(1, listener.getPendingCaptureCount()); + assertTrue(processed.isEmpty()); ready.set(false); listener.stop(); listener.stop(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(1, overflow.size()); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); overflow.close(); + var rows = disk(); assertEquals(1, rows.size()); var row = rows.getFirst(); + UUID id = UUID.fromString((String) row.get("LocalOccurrenceId")); long time = ((Number) row.get("Time")).longValue(); + assertTrue(time >= before && time <= after); assertTrue(processed.isEmpty()); + doAnswer(call -> null).when(serverData).addServiceSite(SITE); + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + try (var restarted = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> true)) { + workerTurn(restarted); assertEquals(1, restarted.size()); + var delivered = new CountDownLatch(1); + doAnswer(call -> { processed.add(call.getArgument(0)); delivered.countDown(); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + restarted.start(); assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(restarted); + assertEquals(0, restarted.size()); assertEquals(1, processed.size()); + assertEquals(id, processed.getFirst().getLocalOccurrenceId()); assertEquals(time, processed.getFirst().getCanonicalOccurrenceTime()); + } + try (var again = new VotifierVoteOverflowQueue(plugin, (site, user) -> fail("completed receipt replayed"))) { assertEquals(0, again.size()); } + } finally { release.countDown(); } + } + @Test void completedBeforeTransferAdmissionReconcilesExactOccurrence() throws Exception { + completedCurrentCallbackReconcilesExactTransferredOccurrence("before-admission"); + } + @Test void completedAfterTransferAdmissionReconcilesExactOccurrence() throws Exception { + completedCurrentCallbackReconcilesExactTransferredOccurrence("after-admission"); + } + private void completedCurrentCallbackReconcilesExactTransferredOccurrence(String completionOrder) throws Exception { + UUID unrelated = UUID.randomUUID(); assertTrue(overflow.enqueue("Steve", SITE, 123L, unrelated)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + listener.onVotiferEvent(receipt()); assertTrue(entered.await(5, TimeUnit.SECONDS)); assertEquals(1, listener.getPendingCaptureCount()); + var transferEntered = new CountDownLatch(1); var releaseTransfer = new CountDownLatch(1); + if (completionOrder.equals("before-admission")) { + worker(overflow).execute(() -> { + transferEntered.countDown(); + try { if (!releaseTransfer.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test transfer timeout"); } + catch (InterruptedException failure) { throw new CompletionException(failure); } + }); + assertTrue(transferEntered.await(5, TimeUnit.SECONDS)); + } + try { + ready.set(false); listener.stop(); + if (completionOrder.equals("after-admission")) { workerTurn(overflow); assertEquals(2, overflow.size()); } + release.countDown(); processingTurn(); assertEquals(1, processed.size()); + UUID completed = processed.getFirst().getLocalOccurrenceId(); assertNotEquals(unrelated, completed); + releaseTransfer.countDown(); workerTurn(overflow); assertEquals(0, listener.getPendingCaptureCount()); + assertEquals(1, overflow.size()); overflow.close(); assertEquals(1, disk().size()); + assertEquals(unrelated.toString(), disk().getFirst().get("LocalOccurrenceId")); + var delivered = new CountDownLatch(1); + doAnswer(call -> { processed.add(call.getArgument(0)); delivered.countDown(); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + try (var restarted = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> true)) { + workerTurn(restarted); restarted.start(); assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(restarted); + assertEquals(0, restarted.size()); assertEquals(2, processed.size()); + assertEquals(1, processed.stream().filter(event -> completed.equals(event.getLocalOccurrenceId())).count()); + assertEquals(unrelated, processed.getLast().getLocalOccurrenceId()); + } + } finally { release.countDown(); releaseTransfer.countDown(); } + } + @Test void uncertainPartialProcessingKeepsItsTransferredRecoveryRecord() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var effects = new AtomicInteger(); + doAnswer(call -> { + effects.incrementAndGet(); entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + throw new IllegalStateException("consumer failed after a partial effect"); + }).when(events).callEvent(any(PlayerVoteEvent.class)); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); + release.countDown(); processingTurn(); assertEquals(1, effects.get()); assertEquals(1, overflow.size()); + overflow.close(); assertEquals(1, disk().size()); + } finally { release.countDown(); } + } + @Test void standaloneStopCannotDrainStartedTransferWhileItsOriginalCallbackRuns() throws Exception { + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + overflow.start(); listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); listener.stop(); + // Readiness remains true: the per-entry release fence must protect this + // path even when a caller stops capture without the main shutdown fence. + workerTurn(overflow); workerTurn(overflow); assertEquals(1, overflow.size()); + assertTrue(processed.isEmpty()); release.countDown(); processingTurn(); workerTurn(overflow); + assertEquals(1, processed.size(), "an eagerly submitted overflow retry would duplicate normal effects"); + assertEquals(0, overflow.size()); overflow.close(); assertTrue(disk().isEmpty()); + } finally { release.countDown(); } + } + @Test void ordinaryRejectedAdmissionTransfersAnUnstartedReceiptThroughTheSameQueue() throws Exception { + var admissions = new AtomicInteger(); var wrapper = mock(ScheduledExecutorService.class); + doAnswer(call -> { + if (admissions.getAndIncrement() == 0) throw new RejectedExecutionException("initial admission rejected"); + return timer.submit(call.getArgument(0, Runnable.class)); + }).when(wrapper).submit(any(Runnable.class)); when(plugin.getVoteTimer()).thenReturn(wrapper); + var delivered = new CountDownLatch(1); + doAnswer(call -> { processed.add(call.getArgument(0)); delivered.countDown(); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); + overflow.start(); listener.onVotiferEvent(receipt()); + assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(overflow); + assertEquals(1, processed.size()); assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); + } + @Test void failedFullQueueTransferRetainsStartedOwnershipAndExactCapacity() throws Exception { + for (int index = 0; index < 256; index++) assertTrue(overflow.enqueue("Steve", SITE, 123L)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException("interrupted", interrupted); } + return null; + }).when(serverData).addServiceSite(SITE); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); + assertEquals(1, listener.getPendingCaptureCount()); assertEquals(256, overflow.size()); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(1, listener.getPendingCaptureCount()); assertTrue(processed.isEmpty()); + overflow.close(); assertEquals(256, disk().size()); + } finally { release.countDown(); } + } + @Test void completionAfterSealedSnapshotReportsUncertaintyWithoutRetiredOverwrite() throws Exception { + var logger = mock(Logger.class); when(plugin.getLogger()).thenReturn(logger); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + listener.onVotiferEvent(receipt()); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); overflow.close(); + byte[] sealed = Files.readAllBytes(directory.resolve("VotifierVoteQueue.yml")); assertEquals(1, disk().size()); + release.countDown(); processingTurn(); assertEquals(1, processed.size()); + verify(logger).warning(contains("outlived queue shutdown")); + assertArrayEquals(sealed, Files.readAllBytes(directory.resolve("VotifierVoteQueue.yml"))); + } finally { release.countDown(); } + } +} diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 00b88ba06e..9593f0f9f1 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -68,10 +68,12 @@ Canonical date accounting requires an explicit transport vote ID; a legacy total Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. -Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries). A bounded 256-entry ingress registry owns validated receipts before executor submission and releases each only when normal processing starts or overflow confirms transfer. Retirement transfers callbacks that have not started before vote-executor cancellation, preserving the receipt UUID/time. The overflow is published as a memory owner before listener registration; its independent worker loads recovered rows before accepting transfers, so cancelling the vote executor cannot cancel queue setup or overwrite recovered receipts. Queue-file loading and ordinary persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. A full recovered queue, an over-capacity persisted file or an unreadable queue explicitly rejects new transfers without evicting recovered votes or replacing the rejected file. Initialization and queued transfers remain bounded; they are not additional reward or accounting engines. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. +Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries). A bounded 256-entry ingress registry owns validated receipts before executor submission and releases each only when normal processing completes or overflow confirms transfer. STARTED callbacks remain owned while blocked; retirement transfers them as well as callbacks that have not started before vote-executor cancellation, preserving the receipt UUID/time. Each transferred STARTED entry remains fenced from overflow execution until the original callback finishes. Successful, non-interrupted completion acknowledges only that exact transferred occurrence, including completion before or after transfer admission; exceptional/interrupted processing retains the recovery record. Failed transfers retain bounded capture ownership and emit an explicit admission failure. The overflow is published as a memory owner before listener registration; its independent worker loads recovered rows before accepting transfers, so cancelling the vote executor cannot cancel queue setup or overwrite recovered receipts. Queue-file loading and ordinary persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. A full recovered queue, an over-capacity persisted file or an unreadable queue explicitly rejects new transfers without evicting recovered votes or replacing the rejected file. Initialization and queued transfers remain bounded; they are not additional reward or accounting engines. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. Extension events dispatched from a Bukkit/Paper/Folia gameplay thread are admitted to the existing ordered storage worker (at most 64 pending). The asynchronous event flag is not treated as a thread check. Deferred proxy accounting remains unconfirmed/quarantined until reconciliation; submission alone never acknowledges durable accounting. Reload, disable, or overload can withhold such extension occurrences. Native VotingPlugin asynchronous ingress retains synchronous completion reporting. Native queue retirement drains its accepted worker transfers and writes the final snapshot after loading, with a one-second lifecycle wait. A blocked worker or failed write is reported as incomplete publication, not a successful durable admission; sudden process death before asynchronous publication can still lose an unpublished receipt. This does not claim universal exactly-once normal rewards or uninterrupted receipt while bounded owners are full. + +A callback that outlives the bounded shutdown and completes only after the overflow seals its final snapshot remains an uncertain recovery case. The retired queue reports reconciliation is required and retains the occurrence rather than overwriting a successor queue. Partial ordinary side effects followed by an exception are also uncertain: recovery keeps the original UUID/time but can repeat ordinary effects under the existing at-least-once overflow contract. These boundaries do not weaken date-ledger identity, add a reward/accounting engine, or extend shutdown waits. From 42f5819f1e4fb8efba7c07318d44ba075f5f4d6e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 12:35:02 -0600 Subject: [PATCH 17/22] Normalize added date milestone lines for diff checks --- .../events/PlayerPostVoteEvent.java | 10 +- .../votingplugin/user/VotingPluginUser.java | 26 +- .../src/main/resources/SpecialRewards.yml | 2 +- ...ckendDateVoteAccountingRegressionTest.java | 660 +++++++++--------- 4 files changed, 349 insertions(+), 349 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java index d68eba18bd..fe5d9dc7c8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerPostVoteEvent.java @@ -82,11 +82,11 @@ public static HandlerList getHandlerList() { @Setter private UUID voteUUID; - /** Original explicit proxy transport ID; voteUUID retains the legacy correlation fallback. */ - @Getter - @Setter - private UUID proxyVoteId; - + /** Original explicit proxy transport ID; voteUUID retains the legacy correlation fallback. */ + @Getter + @Setter + private UUID proxyVoteId; + /** * Constructs a new PlayerPostVoteEvent. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 5eb5433ce2..ef053ae457 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -983,18 +983,18 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps proxyDelayValidationKnown, proxyQueueClassificationKnown, proxyVoteId, targetedProxyVote, true); } - /** - * Normal event processing finished, but date accounting was not confirmed. - * A router must finish its remaining normal effects before quarantining the envelope. - */ - public static final class DateMilestoneAccountingException extends IllegalStateException { - private static final long serialVersionUID = 1L; - - private DateMilestoneAccountingException() { - super("Date milestone accounting is incomplete; retain the durable proxy envelope for reconciliation"); - } - } - + /** + * Normal event processing finished, but date accounting was not confirmed. + * A router must finish its remaining normal effects before quarantining the envelope. + */ + public static final class DateMilestoneAccountingException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private DateMilestoneAccountingException() { + super("Date milestone accounting is incomplete; retain the durable proxy envelope for reconciliation"); + } + } + /** Preserve the canonical wire real/test flag; existing callers retain their historical real-vote default. */ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnapshot text, boolean setTotals, boolean wasOnline, boolean broadcast, int num, boolean queuedProxyVote, @@ -1012,7 +1012,7 @@ public void bungeeVotePluginMessaging(String service, long time, VoteTotalsSnaps plugin.debug("Pluginmessaging vote for " + getPlayerName() + " on " + service); PlayerVoteEvent voteEvent = new PlayerVoteEvent(plugin.getVoteSiteManager().getVoteSite(service, true), - getPlayerName(), service, realVote); + getPlayerName(), service, realVote); voteEvent.setBungee(true); voteEvent.setVotingPluginUser(this); voteEvent.setForceBungee(true); diff --git a/VotingPlugin/src/main/resources/SpecialRewards.yml b/VotingPlugin/src/main/resources/SpecialRewards.yml index 554d483630..c9902eeb8a 100644 --- a/VotingPlugin/src/main/resources/SpecialRewards.yml +++ b/VotingPlugin/src/main/resources/SpecialRewards.yml @@ -397,7 +397,7 @@ DailyAwards: Rewards: Messages: Player: '&aYou came in second place in %TopVoter%!' - + # Owner-defined dated events. This example is DISABLED by default. # Set your own dates/rewards, then enable the event. No automatic recurring reset. # Keep the event ID stable to retain progress; never delete its ledger and reuse it. diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java index 50bd4203d3..8ffa8e68f3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/messaging/BackendDateVoteAccountingRegressionTest.java @@ -1,160 +1,160 @@ -package com.bencodez.votingplugin.backendproxy.messaging; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -import org.bukkit.Bukkit; -import org.bukkit.configuration.file.YamlConfiguration; -import org.bukkit.event.Event; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; -import org.mockito.MockedStatic; - -import com.bencodez.advancedcore.api.user.validation.UserValidationResult; -import com.bencodez.advancedcore.api.user.validation.ValidationSource; -import com.bencodez.advancedcore.api.user.validation.ValidationStatus; -import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; -import com.bencodez.votingplugin.VotingPluginMain; -import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; -import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; -import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; -import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; -import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; -import com.bencodez.votingplugin.events.PlayerPostVoteEvent; -import com.bencodez.votingplugin.events.PlayerVoteEvent; -import com.bencodez.votingplugin.listeners.PlayerVoteListener; -import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteLedger; -import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones; -import com.bencodez.votingplugin.user.VotingPluginUser; -import com.bencodez.votingplugin.proxy.VotingPluginWire; -import com.bencodez.votingplugin.votesites.VoteSite; - -/** Exercises the router, native event adapter, accepted listener and real date ledger together. */ -class BackendDateVoteAccountingRegressionTest { - private static final UUID PLAYER = UUID.fromString("e5baec32-9b2c-4fc8-9aed-0e0285e3c33d"); - private static final long OCCURRED_AT = Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); - private static final String SERVICE = "known.example"; - private static final int POINTS = 7; - - @TempDir Path root; - private final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); - private final VotingPluginUser user = mock(VotingPluginUser.class); - private final VoteSite site = mock(VoteSite.class); - private final YamlConfiguration config = new YamlConfiguration(); - private final List accepted = new ArrayList<>(); - private final List posted = new ArrayList<>(); - private final AtomicInteger storedPoints = new AtomicInteger(37); - private final ProcessedVoteCache cache = spy(new ProcessedVoteCache()); - private BackendProxyMessageRouter router; - private MockedStatic bukkit; - private MockedStatic advancedCore; - - @BeforeEach - void setUp() throws Exception { - when(plugin.isEnabled()).thenReturn(true); - when(plugin.getDataFolder()).thenReturn(root.toFile()); - when(plugin.getStorageType()).thenReturn(null); // Exercise ordinary synchronous point storage. - when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); - when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); - when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); - when(plugin.getOptions().isProcessRewards()).thenReturn(true); - when(plugin.getOptions().getServer()).thenReturn("owner"); - advancedCore=mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class); - advancedCore.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); - when(plugin.getConfigFile().getPointsOnVote()).thenReturn(POINTS); - when(plugin.getConfigFile().isAddTotals()).thenReturn(true); - when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); - when(plugin.getUserManager().getProperName("Player")).thenReturn("Player"); - when(plugin.getUserManager().getValidationService().validate("Player", false)).thenReturn( - new UserValidationResult(ValidationStatus.VALID, "Player", ValidationSource.STORAGE, "fixture", false)); - when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER, "Player")).thenReturn(user); - when(plugin.getVoteSiteManager().getVoteSite(SERVICE, true)).thenReturn(site); - when(site.isEnabled()).thenReturn(true); - when(site.getKey()).thenReturn("Example"); - when(site.getServiceSite()).thenReturn(SERVICE); - when(user.getPlayerName()).thenReturn("Player"); - when(user.getUUID()).thenReturn(PLAYER.toString()); - when(user.getJavaUUID()).thenReturn(PLAYER); - when(user.isOnline()).thenReturn(true); - when(user.getPoints()).thenAnswer(ignored -> storedPoints.get()); - doAnswer(call -> { storedPoints.set(call.getArgument(0)); return null; }) - .when(user).setPoints(anyInt(), eq(false)); - var field = VotingPluginUser.class.getDeclaredField("plugin"); - field.setAccessible(true); - field.set(user, plugin); - doCallRealMethod().when(user).addPoints(anyInt()); - doCallRealMethod().when(user).addPoints(anyInt(), anyBoolean()); - doCallRealMethod().when(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), +package com.bencodez.votingplugin.backendproxy.messaging; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Event; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.validation.UserValidationResult; +import com.bencodez.advancedcore.api.user.validation.ValidationSource; +import com.bencodez.advancedcore.api.user.validation.ValidationStatus; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; +import com.bencodez.votingplugin.backendproxy.messaging.BackendProxyMessageRouter.OrderedVoteOutcome; +import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; +import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; +import com.bencodez.votingplugin.events.PlayerPostVoteEvent; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.listeners.PlayerVoteListener; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteLedger; +import com.bencodez.votingplugin.specialrewards.datemilestones.DateVoteMilestones; +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.votesites.VoteSite; + +/** Exercises the router, native event adapter, accepted listener and real date ledger together. */ +class BackendDateVoteAccountingRegressionTest { + private static final UUID PLAYER = UUID.fromString("e5baec32-9b2c-4fc8-9aed-0e0285e3c33d"); + private static final long OCCURRED_AT = Instant.parse("2026-10-15T00:00:00Z").toEpochMilli(); + private static final String SERVICE = "known.example"; + private static final int POINTS = 7; + + @TempDir Path root; + private final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + private final VotingPluginUser user = mock(VotingPluginUser.class); + private final VoteSite site = mock(VoteSite.class); + private final YamlConfiguration config = new YamlConfiguration(); + private final List accepted = new ArrayList<>(); + private final List posted = new ArrayList<>(); + private final AtomicInteger storedPoints = new AtomicInteger(37); + private final ProcessedVoteCache cache = spy(new ProcessedVoteCache()); + private BackendProxyMessageRouter router; + private MockedStatic bukkit; + private MockedStatic advancedCore; + + @BeforeEach + void setUp() throws Exception { + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getDataFolder()).thenReturn(root.toFile()); + when(plugin.getStorageType()).thenReturn(null); // Exercise ordinary synchronous point storage. + when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(true); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getBungeeSettings().getServer()).thenReturn("owner"); + when(plugin.getOptions().isProcessRewards()).thenReturn(true); + when(plugin.getOptions().getServer()).thenReturn("owner"); + advancedCore=mockStatic(com.bencodez.advancedcore.AdvancedCorePlugin.class); + advancedCore.when(com.bencodez.advancedcore.AdvancedCorePlugin::getInstance).thenReturn(plugin); + when(plugin.getConfigFile().getPointsOnVote()).thenReturn(POINTS); + when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + when(plugin.getSpecialRewardsConfig().getData()).thenReturn(config); + when(plugin.getUserManager().getProperName("Player")).thenReturn("Player"); + when(plugin.getUserManager().getValidationService().validate("Player", false)).thenReturn( + new UserValidationResult(ValidationStatus.VALID, "Player", ValidationSource.STORAGE, "fixture", false)); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(PLAYER, "Player")).thenReturn(user); + when(plugin.getVoteSiteManager().getVoteSite(SERVICE, true)).thenReturn(site); + when(site.isEnabled()).thenReturn(true); + when(site.getKey()).thenReturn("Example"); + when(site.getServiceSite()).thenReturn(SERVICE); + when(user.getPlayerName()).thenReturn("Player"); + when(user.getUUID()).thenReturn(PLAYER.toString()); + when(user.getJavaUUID()).thenReturn(PLAYER); + when(user.isOnline()).thenReturn(true); + when(user.getPoints()).thenAnswer(ignored -> storedPoints.get()); + doAnswer(call -> { storedPoints.set(call.getArgument(0)); return null; }) + .when(user).setPoints(anyInt(), eq(false)); + var field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(user, plugin); + doCallRealMethod().when(user).addPoints(anyInt()); + doCallRealMethod().when(user).addPoints(anyInt(), anyBoolean()); + doCallRealMethod().when(user).bungeeVotePluginMessaging(any(), anyLong(), any(), anyBoolean(), anyBoolean(), anyBoolean(), anyInt(), anyBoolean(), anyBoolean(), anyBoolean(), any(), anyBoolean(), anyBoolean(), anyLong()); - - var dispatcher = plugin.getServer().getPluginManager(); - var listener = new PlayerVoteListener(plugin); - doAnswer(call -> { - Event event = call.getArgument(0); - if (event instanceof PlayerVoteEvent vote) { - accepted.add(vote); - listener.onplayerVote(vote); - } else if (event instanceof PlayerPostVoteEvent post) { - posted.add(post); - } - return null; - }).when(dispatcher).callEvent(any()); - bukkit = mockStatic(Bukkit.class); - bukkit.when(Bukkit::getPluginManager).thenReturn(dispatcher); - router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), - mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); - definition("october"); - reloadDefinitions(); - } - - @AfterEach - void closeBukkit() { - if (bukkit != null) bukkit.close(); - if (advancedCore != null) advancedCore.close(); - } - - private void definition(String id) { - String prefix = "DateVoteMilestones." + id + "."; - config.set(prefix + "Enabled", true); - config.set(prefix + "Start", "2026-10-01T00:00:00"); - config.set(prefix + "End", "2026-11-01T00:00:00"); - config.set(prefix + "Timezone", "UTC"); - config.set(prefix + "AccountingServer", "owner"); - config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); - } - - private void reloadDefinitions() { - var milestones = new DateVoteMilestones(plugin); - milestones.reload(); - when(plugin.getDateVoteMilestones()).thenReturn(milestones); - } - - private DateVoteLedger.Progress progress(String id) throws Exception { - var event = DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id)); - return new DateVoteLedger(root.resolve("date-vote-milestones")).progress(event, PLAYER); - } - - private JsonEnvelope vote(UUID id, boolean queued) { - var original = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, - true, true, "", id, false, false, 1, 1, true); - var builder = JsonEnvelope.builder(original.getSubChannel()).schema(original.getSchema()); - original.getFields().forEach(builder::put); - return VotingPluginWire.requestVoteDeliveryAcknowledgement( - builder.put(VotingPluginWire.K_QUEUED_DELIVERY, queued).build()); - } - + + var dispatcher = plugin.getServer().getPluginManager(); + var listener = new PlayerVoteListener(plugin); + doAnswer(call -> { + Event event = call.getArgument(0); + if (event instanceof PlayerVoteEvent vote) { + accepted.add(vote); + listener.onplayerVote(vote); + } else if (event instanceof PlayerPostVoteEvent post) { + posted.add(post); + } + return null; + }).when(dispatcher).callEvent(any()); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(dispatcher); + router = new BackendProxyMessageRouter(plugin, mock(BackendPresenceManager.class), + mock(BackendGlobalDataSync.class), mock(BackendVotePartySync.class), cache); + definition("october"); + reloadDefinitions(); + } + + @AfterEach + void closeBukkit() { + if (bukkit != null) bukkit.close(); + if (advancedCore != null) advancedCore.close(); + } + + private void definition(String id) { + String prefix = "DateVoteMilestones." + id + "."; + config.set(prefix + "Enabled", true); + config.set(prefix + "Start", "2026-10-01T00:00:00"); + config.set(prefix + "End", "2026-11-01T00:00:00"); + config.set(prefix + "Timezone", "UTC"); + config.set(prefix + "AccountingServer", "owner"); + config.set(prefix + "Milestones.1.Rewards.Messages.Player", "Thanks"); + } + + private void reloadDefinitions() { + var milestones = new DateVoteMilestones(plugin); + milestones.reload(); + when(plugin.getDateVoteMilestones()).thenReturn(milestones); + } + + private DateVoteLedger.Progress progress(String id) throws Exception { + var event = DateVoteMilestones.parse(id, config.getConfigurationSection("DateVoteMilestones." + id)); + return new DateVoteLedger(root.resolve("date-vote-milestones")).progress(event, PLAYER); + } + + private JsonEnvelope vote(UUID id, boolean queued) { + var original = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "", id, false, false, 1, 1, true); + var builder = JsonEnvelope.builder(original.getSubChannel()).schema(original.getSchema()); + original.getFields().forEach(builder::put); + return VotingPluginWire.requestVoteDeliveryAcknowledgement( + builder.put(VotingPluginWire.K_QUEUED_DELIVERY, queued).build()); + } + @Test void reliableForwardingSeparatesOriginalMembershipFromReceiverCooldownTime() throws Exception { config.set("DateVoteMilestones.october.Start", "2026-10-15T00:00:00"); @@ -338,177 +338,177 @@ private JsonEnvelope receiverEnvelope(UUID id, long senderTime, Long originalTim return emitted; } - @Test - void liveAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { - assertAccountingFailureFinishesNormalEffects(false); - } - - @Test - void queuedAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { - assertAccountingFailureFinishesNormalEffects(true); - } - - private void assertAccountingFailureFinishesNormalEffects(boolean queued) throws Exception { - definition("sibling"); - reloadDefinitions(); - var event = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); - Path corrupt = root.resolve("date-vote-milestones").resolve(event.fileId() + "-" + PLAYER + ".properties"); - Files.createDirectories(corrupt.getParent()); - Files.writeString(corrupt, "corrupt fixture record"); - // A corrupt player record needs its existing definition seal, just as an installed ledger would have. - var definitions = new java.util.Properties(); - definitions.setProperty("october", event.fingerprint()); - try (var writer = Files.newBufferedWriter(corrupt.getParent().resolve("definitions.properties"))) { - definitions.store(writer, "fixture"); - } - UUID id = UUID.randomUUID(); - JsonEnvelope envelope = vote(id, queued); - var outcomes = new ArrayList(); - assertThrows(VotingPluginUser.DateMilestoneAccountingException.class, - () -> router.handleOrderedVote(envelope, outcome -> { - // The lane may quarantine only after the remaining normal mutation and observation. - assertEquals(44, storedPoints.get()); - verify(plugin.getServerData()).addServiceSite(SERVICE); - outcomes.add(outcome); - })); - assertEquals(List.of(OrderedVoteOutcome.QUARANTINE), outcomes); - assertTrue(accepted.get(0).isDateMilestoneAccountingFailed()); - assertEquals(id, accepted.get(0).getProxyVoteId()); - assertEquals(id, posted.get(0).getProxyVoteId()); - assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); - assertEquals("corrupt fixture record", Files.readString(corrupt)); - assertEquals(1, progress("sibling").votes()); - assertEquals(java.util.Set.of(1), progress("sibling").submittedAwards()); - - router.handleOrderedVote(envelope, outcomes::add); - assertEquals(List.of(OrderedVoteOutcome.QUARANTINE, OrderedVoteOutcome.QUARANTINE), outcomes); - assertEquals(44, storedPoints.get()); - assertEquals(1, accepted.size()); - assertEquals(1, posted.size()); - assertFalse(cache.hasCompletedEffects(id)); - verify(cache, never()).complete(id); - assertNormalEffectsOnce(id, OCCURRED_AT); - verify(user).addPoints(POINTS); - verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); - } - - private void assertNormalEffectsOnce(UUID id, long time) { - verify(user).playerVote(site, true, true); - verify(user).addTotal(); - verify(user).addTotalDaily(); - verify(user).addTotalWeekly(); - verify(user).addPoints(); - verify(user).setTime(site, time); - verify(plugin.getVoteParty()).vote(user, true, true, true); - verify(plugin.getVoteMilestonesManager()).handleVote(eq(user), any(), eq(true), eq(id), any()); - verify(plugin.getCoolDownCheck()).vote(user, site); - verify(plugin.getVoteStreakHandler()).processVote(user, time, id); - verify(plugin).setUpdate(true); - } - - @Test - void missingWireIdKeepsLegacyTotalsCorrelationAndDedupWithoutDateAccounting() throws Exception { - UUID correlation = UUID.randomUUID(); - // Actual legacy storage layout, including the old ignored milestone-count column. - String totals = "1//2//3//4//5//0//6//7//8//" + correlation; - JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).schema(VotingPluginWire.SCHEMA_VERSION) - .put(VotingPluginWire.K_PLAYER, "Player").put(VotingPluginWire.K_UUID, PLAYER) - .put(VotingPluginWire.K_SERVICE, SERVICE).put(VotingPluginWire.K_TIME, OCCURRED_AT) - .put(VotingPluginWire.K_WAS_ONLINE, true).put(VotingPluginWire.K_REAL_VOTE, true) - .put(VotingPluginWire.K_TOTALS, totals).build(); - assertFalse(envelope.getFields().containsKey(VotingPluginWire.K_VOTE_ID)); - assertLegacyCorrelation(envelope, correlation); - } - - @Test - void emptyWireIdKeepsVersionedTotalsCorrelationWithoutDateAccounting() throws Exception { - UUID correlation = UUID.randomUUID(); - JsonEnvelope envelope = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, - true, true, "v2//1//2//3//4//5//6//7//8//" + correlation, null, false, false, 1, 1); - assertEquals("", envelope.getFields().get(VotingPluginWire.K_VOTE_ID)); - assertLegacyCorrelation(envelope, correlation); - } - - private void assertLegacyCorrelation(JsonEnvelope envelope, UUID correlation) throws Exception { - var outcome = new AtomicReference(); - router.handleOrderedVote(envelope, outcome::set); - assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); - router.handleOrderedVote(envelope, outcome::set); - assertEquals(1, accepted.size()); - assertEquals(1, posted.size()); - assertNull(accepted.get(0).getProxyVoteId()); - assertNull(posted.get(0).getProxyVoteId()); - assertEquals(correlation, posted.get(0).getVoteUUID()); - assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); - assertEquals(0, progress("october").votes()); - assertFalse(Files.exists(root.resolve("date-vote-milestones"))); - assertEquals(44, storedPoints.get()); - assertNormalEffectsOnce(correlation, OCCURRED_AT); - verify(cache, times(2)).reserveWithOutcome(correlation); - verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); - } - - @Test - void targetedWireIdentityRemainsExplicitButDoesNotEnterTheOwnerLedger() throws Exception { - UUID id = UUID.randomUUID(); - router.handleOrderedVote(VotingPluginWire.voteOnline("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, - true, true, "", id, false, false, 1, 1), ignored -> { }); - assertTrue(accepted.get(0).isTargetedProxyVote()); - assertEquals(id, posted.get(0).getProxyVoteId()); - assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); - assertEquals(0, progress("october").votes()); - assertNormalEffectsOnce(id, OCCURRED_AT); - } - - @Test - void canonicalTestFlagRemainsExcludedAcrossRouterAndAcceptedListener() throws Exception { - UUID id = UUID.randomUUID(); - router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, - true, false, "", id, false, false, 1, 1), ignored -> { }); - assertFalse(accepted.get(0).isRealVote()); - assertFalse(posted.get(0).isRealVote()); - assertEquals(id, posted.get(0).getProxyVoteId()); - assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); - assertEquals(0, progress("october").votes()); - verify(user).playerVote(site, true, true); - verify(user, never()).addTotal(); - verify(user).addPoints(POINTS); // Preserve the existing router's per-server points policy. - } - - @Test - void missingOriginalTimeIsNotMadeCanonicalByNormalPostVoteTimestampFallback() throws Exception { - UUID id = UUID.randomUUID(); - long before = System.currentTimeMillis(); - router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, 0, - true, true, "", id, false, false, 1, 1), ignored -> { }); - assertEquals(0, accepted.get(0).getTime()); - assertEquals(id, posted.get(0).getProxyVoteId()); - assertTrue(posted.get(0).getVoteTime() >= before); - assertTrue(posted.get(0).getVoteTime() <= System.currentTimeMillis()); - assertEquals(0, progress("october").votes()); - verify(user).setTime(site); - } - - @Test - void explicitWireIdOverridesTotalsCorrelationThroughDateAccountingAndPostVote() throws Exception { - UUID wireId = UUID.randomUUID(), correlation = UUID.randomUUID(); - JsonEnvelope envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote( - "Player", PLAYER.toString(), SERVICE, OCCURRED_AT, true, true, - "v2//1//2//3//4//5//6//7//8//" + correlation, wireId, false, false, 1, 1)); - var outcome = new AtomicReference(); - router.handleOrderedVote(envelope, outcome::set); - assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); - router.handleOrderedVote(envelope, outcome::set); - assertEquals(1, accepted.size()); - assertEquals(1, posted.size()); - assertEquals(wireId, accepted.get(0).getProxyVoteId()); - assertEquals(wireId, posted.get(0).getProxyVoteId()); - assertEquals(wireId, posted.get(0).getVoteUUID()); - assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); - assertEquals(1, progress("october").votes()); - assertNormalEffectsOnce(wireId, OCCURRED_AT); - verify(cache, times(2)).reserveWithOutcome(wireId); - verify(cache, never()).reserveWithOutcome(correlation); - verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); - } -} + @Test + void liveAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { + assertAccountingFailureFinishesNormalEffects(false); + } + + @Test + void queuedAccountingFailureFinishesNormalEffectsBeforeQuarantineAndDoesNotReplayThem() throws Exception { + assertAccountingFailureFinishesNormalEffects(true); + } + + private void assertAccountingFailureFinishesNormalEffects(boolean queued) throws Exception { + definition("sibling"); + reloadDefinitions(); + var event = DateVoteMilestones.parse("october", config.getConfigurationSection("DateVoteMilestones.october")); + Path corrupt = root.resolve("date-vote-milestones").resolve(event.fileId() + "-" + PLAYER + ".properties"); + Files.createDirectories(corrupt.getParent()); + Files.writeString(corrupt, "corrupt fixture record"); + // A corrupt player record needs its existing definition seal, just as an installed ledger would have. + var definitions = new java.util.Properties(); + definitions.setProperty("october", event.fingerprint()); + try (var writer = Files.newBufferedWriter(corrupt.getParent().resolve("definitions.properties"))) { + definitions.store(writer, "fixture"); + } + UUID id = UUID.randomUUID(); + JsonEnvelope envelope = vote(id, queued); + var outcomes = new ArrayList(); + assertThrows(VotingPluginUser.DateMilestoneAccountingException.class, + () -> router.handleOrderedVote(envelope, outcome -> { + // The lane may quarantine only after the remaining normal mutation and observation. + assertEquals(44, storedPoints.get()); + verify(plugin.getServerData()).addServiceSite(SERVICE); + outcomes.add(outcome); + })); + assertEquals(List.of(OrderedVoteOutcome.QUARANTINE), outcomes); + assertTrue(accepted.get(0).isDateMilestoneAccountingFailed()); + assertEquals(id, accepted.get(0).getProxyVoteId()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals("corrupt fixture record", Files.readString(corrupt)); + assertEquals(1, progress("sibling").votes()); + assertEquals(java.util.Set.of(1), progress("sibling").submittedAwards()); + + router.handleOrderedVote(envelope, outcomes::add); + assertEquals(List.of(OrderedVoteOutcome.QUARANTINE, OrderedVoteOutcome.QUARANTINE), outcomes); + assertEquals(44, storedPoints.get()); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertFalse(cache.hasCompletedEffects(id)); + verify(cache, never()).complete(id); + assertNormalEffectsOnce(id, OCCURRED_AT); + verify(user).addPoints(POINTS); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } + + private void assertNormalEffectsOnce(UUID id, long time) { + verify(user).playerVote(site, true, true); + verify(user).addTotal(); + verify(user).addTotalDaily(); + verify(user).addTotalWeekly(); + verify(user).addPoints(); + verify(user).setTime(site, time); + verify(plugin.getVoteParty()).vote(user, true, true, true); + verify(plugin.getVoteMilestonesManager()).handleVote(eq(user), any(), eq(true), eq(id), any()); + verify(plugin.getCoolDownCheck()).vote(user, site); + verify(plugin.getVoteStreakHandler()).processVote(user, time, id); + verify(plugin).setUpdate(true); + } + + @Test + void missingWireIdKeepsLegacyTotalsCorrelationAndDedupWithoutDateAccounting() throws Exception { + UUID correlation = UUID.randomUUID(); + // Actual legacy storage layout, including the old ignored milestone-count column. + String totals = "1//2//3//4//5//0//6//7//8//" + correlation; + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE).schema(VotingPluginWire.SCHEMA_VERSION) + .put(VotingPluginWire.K_PLAYER, "Player").put(VotingPluginWire.K_UUID, PLAYER) + .put(VotingPluginWire.K_SERVICE, SERVICE).put(VotingPluginWire.K_TIME, OCCURRED_AT) + .put(VotingPluginWire.K_WAS_ONLINE, true).put(VotingPluginWire.K_REAL_VOTE, true) + .put(VotingPluginWire.K_TOTALS, totals).build(); + assertFalse(envelope.getFields().containsKey(VotingPluginWire.K_VOTE_ID)); + assertLegacyCorrelation(envelope, correlation); + } + + @Test + void emptyWireIdKeepsVersionedTotalsCorrelationWithoutDateAccounting() throws Exception { + UUID correlation = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "v2//1//2//3//4//5//6//7//8//" + correlation, null, false, false, 1, 1); + assertEquals("", envelope.getFields().get(VotingPluginWire.K_VOTE_ID)); + assertLegacyCorrelation(envelope, correlation); + } + + private void assertLegacyCorrelation(JsonEnvelope envelope, UUID correlation) throws Exception { + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertNull(accepted.get(0).getProxyVoteId()); + assertNull(posted.get(0).getProxyVoteId()); + assertEquals(correlation, posted.get(0).getVoteUUID()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + assertFalse(Files.exists(root.resolve("date-vote-milestones"))); + assertEquals(44, storedPoints.get()); + assertNormalEffectsOnce(correlation, OCCURRED_AT); + verify(cache, times(2)).reserveWithOutcome(correlation); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), anyString(), any()); + } + + @Test + void targetedWireIdentityRemainsExplicitButDoesNotEnterTheOwnerLedger() throws Exception { + UUID id = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.voteOnline("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, true, "", id, false, false, 1, 1), ignored -> { }); + assertTrue(accepted.get(0).isTargetedProxyVote()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + assertNormalEffectsOnce(id, OCCURRED_AT); + } + + @Test + void canonicalTestFlagRemainsExcludedAcrossRouterAndAcceptedListener() throws Exception { + UUID id = UUID.randomUUID(); + router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, OCCURRED_AT, + true, false, "", id, false, false, 1, 1), ignored -> { }); + assertFalse(accepted.get(0).isRealVote()); + assertFalse(posted.get(0).isRealVote()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(0, progress("october").votes()); + verify(user).playerVote(site, true, true); + verify(user, never()).addTotal(); + verify(user).addPoints(POINTS); // Preserve the existing router's per-server points policy. + } + + @Test + void missingOriginalTimeIsNotMadeCanonicalByNormalPostVoteTimestampFallback() throws Exception { + UUID id = UUID.randomUUID(); + long before = System.currentTimeMillis(); + router.handleOrderedVote(VotingPluginWire.vote("Player", PLAYER.toString(), SERVICE, 0, + true, true, "", id, false, false, 1, 1), ignored -> { }); + assertEquals(0, accepted.get(0).getTime()); + assertEquals(id, posted.get(0).getProxyVoteId()); + assertTrue(posted.get(0).getVoteTime() >= before); + assertTrue(posted.get(0).getVoteTime() <= System.currentTimeMillis()); + assertEquals(0, progress("october").votes()); + verify(user).setTime(site); + } + + @Test + void explicitWireIdOverridesTotalsCorrelationThroughDateAccountingAndPostVote() throws Exception { + UUID wireId = UUID.randomUUID(), correlation = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.requestVoteDeliveryAcknowledgement(VotingPluginWire.vote( + "Player", PLAYER.toString(), SERVICE, OCCURRED_AT, true, true, + "v2//1//2//3//4//5//6//7//8//" + correlation, wireId, false, false, 1, 1)); + var outcome = new AtomicReference(); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(OrderedVoteOutcome.COMPLETE, outcome.get()); + router.handleOrderedVote(envelope, outcome::set); + assertEquals(1, accepted.size()); + assertEquals(1, posted.size()); + assertEquals(wireId, accepted.get(0).getProxyVoteId()); + assertEquals(wireId, posted.get(0).getProxyVoteId()); + assertEquals(wireId, posted.get(0).getVoteUUID()); + assertEquals(OCCURRED_AT, posted.get(0).getVoteTime()); + assertEquals(1, progress("october").votes()); + assertNormalEffectsOnce(wireId, OCCURRED_AT); + verify(cache, times(2)).reserveWithOutcome(wireId); + verify(cache, never()).reserveWithOutcome(correlation); + verify(plugin.getRewardHandler()).giveReward(eq(user), any(), anyString(), any()); + } +} From cf51409f3f3ee5037333be40f76555b306f4d296 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:58:53 -0600 Subject: [PATCH 18/22] Retain bounded vote captures across failed overflow transfers --- .../votingplugin/listeners/VotiferEvent.java | 29 ++++- .../listeners/VotifierVoteOverflowQueue.java | 19 ++++ .../DateVotifierStartupCaptureTest.java | 51 +++++++++ .../VotiferEventDisabledVoteSiteTest.java | 5 +- .../VotifierStartedCaptureOwnershipTest.java | 103 ++++++++++++++++++ docs/date-vote-milestones.md | 2 + 6 files changed, 202 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index b721fa7613..f8c6bfde96 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -32,7 +32,7 @@ private static final class CapturedVote { final String username, site; final long occurredAt; final UUID id; - boolean transferring; + boolean transferring, transferFailureReported; volatile boolean started, completed, finished, transferred; VotifierVoteOverflowQueue recoveryOwner; CapturedVote(String username, String site, long occurredAt, UUID id) { @@ -98,16 +98,32 @@ private void transferLocked(CapturedVote vote) { } private void transferred(CapturedVote vote, boolean admitted) { - boolean completed; + boolean completed, retry, reportFailure; synchronized (captureLock) { vote.transferred = admitted; completed = vote.completed; + if (!admitted) vote.transferring = false; if (admitted || completed) captured.remove(vote.id); + retry = !admitted && !completed && captured.get(vote.id) == vote; + reportFailure = !admitted && !completed && !vote.transferFailureReported; + if (reportFailure) vote.transferFailureReported = true; } if (admitted && completed) vote.recoveryOwner.acknowledgeOccurrence(vote.id); if (completed) return; // Current normal processing won; do not leave an ordinary duplicate retry. - if (!admitted) plugin.getLogger().severe("Votifier vote queue unavailable or full; vote was not admitted for " + if (reportFailure) plugin.getLogger().severe("Votifier vote queue unavailable or full; " + + (retry ? "captured receipt retained for retry for " : "vote was not admitted for ") + MinecraftUsernameValidator.sanitizeForLog(vote.username)); - else plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry"); + else if (admitted) plugin.getLogger().warning("Vote pipeline unavailable or retiring; queued Votifier vote for retry"); + if (retry && vote.recoveryOwner != null) vote.recoveryOwner.retryCaptureTransfers(this::retryCapturedTransfers); + } + + private void retryCapturedTransfers() { + synchronized (captureLock) { + for (CapturedVote vote : java.util.List.copyOf(captured.values())) { + // Ordinary queued/running callbacks retain their original execution. + // Only receipts which already attempted a failed handoff need retry. + if (vote.recoveryOwner != null && !vote.transferring) transferLocked(vote); + } + } } /** @@ -239,8 +255,9 @@ public void onVotiferEvent(VotifierEvent event) { synchronized (captureLock) { if (!accepting) return; if (captured.size() >= MAX_CAPTURED) { - plugin.getLogger().severe("Votifier capture capacity exhausted; vote was not admitted for " - + MinecraftUsernameValidator.sanitizeForLog(voteUsername)); + // The overflow owns a separate bounded admission lane; do not + // require another normal capture/executor slot to reach it. + transferLocked(capturedVote); return; } captured.put(localOccurrenceId, capturedVote); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 32878f6257..59ec8c4357 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -56,6 +56,7 @@ public interface OccurrenceProcessor { void process(String serviceSite, String u private final Object persistenceWriteLock = new Object(); private final ArrayDeque entries = new ArrayDeque<>(); private boolean drainScheduled; + private boolean captureRetryScheduled; private boolean persistenceScheduled; private boolean persistenceDirty; private boolean started; @@ -295,6 +296,24 @@ public void acknowledgeOccurrence(UUID localOccurrenceId) { /** Wake the same bounded drain when an original transferred callback finishes uncertainly. */ public void resumeTransfers() { scheduleDrain(); } + /** One wakeup for the ingress listener's bounded failed-transfer captures. + * Retry runs outside the queue monitor and never adds another executor or waiter list. */ + public void retryCaptureTransfers(Runnable retry) { + synchronized (lock) { + if (closed || closing || captureRetryScheduled) return; + captureRetryScheduled = true; + try { + worker.schedule(() -> { + synchronized (lock) { + captureRetryScheduled = false; + if (closed || closing) return; + } + retry.run(); + }, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException rejected) { captureRetryScheduled = false; } + } + } + private void requestPersistenceLocked() { persistenceDirty = true; if (persistenceScheduled) return; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java index 75524c8060..bc4a1afb19 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -57,6 +57,13 @@ private final class Fixture implements AutoCloseable { when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); when(plugin.getOptions().isProcessRewards()).thenReturn(true); when(plugin.getConfigFile().isAddTotals()).thenReturn(true); + // Create worker-used deep stubs before concurrent overflow admission + // and ordinary processing; Mockito cannot safely stub on both workers. + plugin.getServerData(); plugin.getBungeeSettings(); plugin.getTimeChecker(); + plugin.getBroadcastHandler(); plugin.getVoteParty(); plugin.getCoolDownCheck(); + plugin.getVoteMilestonesManager(); plugin.getVoteStreakHandler(); + plugin.getPlaceholders(); plugin.getConfigFile().getPlaceholderCacheLevel(); + plugin.getRewardHandler(); when(plugin.getUserManager().getProperName("Steve")).thenReturn("Steve"); when(plugin.getUserManager().getValidationService().validate("Steve", false)).thenReturn( new UserValidationResult(ValidationStatus.VALID, "Steve", ValidationSource.STORAGE, "fixture", false)); @@ -232,6 +239,50 @@ public void close() { capturedAdmissionSurvivesCancellation(true); } + @Test void fullRecoveredStartupQueueRetriesNewCaptureAndCountsItsOriginalReceiptOnce() throws Exception { + var rows = new ArrayList>(); + var recoveredIds = new HashSet(); + long recoveredTime = System.currentTimeMillis() - 1000; + for (int index = 0; index < 256; index++) { + UUID id = UUID.randomUUID(); recoveredIds.add(id); + rows.add(Map.of("Username", "Steve", "ServiceSite", SERVICE, "Time", recoveredTime, + "LocalOccurrenceId", id.toString())); + } + var yaml = new YamlConfiguration(); yaml.set("Votes", rows); + yaml.save(root.resolve("VotifierVoteQueue.yml").toFile()); + try (var fixture = new Fixture(root, 257)) { + when(fixture.plugin.getLogger()).thenReturn(mock(Logger.class)); + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); + var overflow = fixture.plugin.getVotifierVoteOverflowQueue(); assertEquals(256, overflow.size()); + fixture.vote(); fixture.timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + var workerField = VotifierVoteOverflowQueue.class.getDeclaredField("worker"); workerField.setAccessible(true); + var worker = (ScheduledExecutorService) workerField.get(overflow); + worker.submit(() -> { }).get(3, TimeUnit.SECONDS); + assertEquals(1, fixture.capture().getPendingCaptureCount()); assertTrue(fixture.accepted.isEmpty()); + var capturesField = VotiferEvent.class.getDeclaredField("captured"); capturesField.setAccessible(true); + var captures = (Map) capturesField.get(fixture.capture()); + UUID original = (UUID) captures.keySet().iterator().next(); + var capture = captures.values().iterator().next(); + var timeField = capture.getClass().getDeclaredField("occurredAt"); timeField.setAccessible(true); + long originalTime = timeField.getLong(capture); + fixture.open(); + assertTrue(fixture.delivered.await(10, TimeUnit.SECONDS), "Startup must retry capture after recovered overflow drains"); + fixture.drain(); assertEquals(257, fixture.accepted.size()); assertEquals(257, fixture.posted.size()); + assertEquals(257, fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + assertTrue(fixture.accepted.stream().map(PlayerVoteEvent::getLocalOccurrenceId).toList().containsAll(recoveredIds)); + var retried = fixture.accepted.stream().filter(event -> original.equals(event.getLocalOccurrenceId())).toList(); + assertEquals(1, retried.size()); assertEquals(originalTime, retried.getFirst().getCanonicalOccurrenceTime()); + assertEquals(0L, retried.getFirst().getTime()); assertNull(retried.getFirst().getProxyVoteId()); + var post = fixture.posted.stream().filter(event -> original.equals(event.getVoteUUID())).toList(); + assertEquals(1, post.size()); assertEquals(originalTime, post.getFirst().getCanonicalOccurrenceTime()); + assertEquals(257, fixture.progress().votes()); + verify(fixture.user, times(257)).setTime(fixture.site); + verify(fixture.user, times(257)).addTotal(); verify(fixture.user, times(257)).playerVote(fixture.site, true, false); + verify(fixture.plugin.getRewardHandler(), times(1)).giveReward(eq(fixture.user), any(), startsWith("DateVoteMilestonesRuntime."), any()); + assertEquals(0, overflow.size()); + } + } + private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Exception { UUID playerId = UUID.randomUUID(); UUID original; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index 75b6203a37..65a7bfd55c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -154,11 +154,14 @@ public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { for (int index = 0; index < 257; index++) listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); org.junit.jupiter.api.Assertions.assertEquals(256, listener.getPendingCaptureCount()); org.junit.jupiter.api.Assertions.assertEquals(256, tasks.size()); + // The 257th receipt uses the independently bounded overflow directly. + verify(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), + org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); listener.stop(); listener.stop(); org.junit.jupiter.api.Assertions.assertEquals(0, listener.getPendingCaptureCount()); // Cancelled callbacks that later run cannot transfer or process again. tasks.forEach(Runnable::run); - verify(overflowQueue, org.mockito.Mockito.times(256)).enqueueAfterInitialization(anyString(), anyString(), + verify(overflowQueue, org.mockito.Mockito.times(257)).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java index 8793ab7440..5bdab71196 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java @@ -15,6 +15,7 @@ import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.listeners.VotiferEvent; import com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue; +import com.bencodez.votingplugin.util.BoundedScheduledExecutor; import com.vexsoftware.votifier.model.Vote; /** Real ingress callback, real executor cancellation, real overflow publication/restart. */ @@ -39,8 +40,14 @@ private static ScheduledExecutorService timer() { when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); when(plugin.getBungeeSettings().isUseBungeecoord()).thenReturn(false); + // Finish mock creation before overflow admission and normal processing + // concurrently use this plugin; deep-stub creation itself is not safe then. + when(plugin.getConfigFile()).thenReturn(mock(com.bencodez.votingplugin.config.Config.class)); + when(plugin.getTimeChecker()).thenReturn(mock(com.bencodez.advancedcore.api.time.TimeChecker.class)); when(plugin.getVoteSiteManager().getVoteSiteName(anyBoolean(), anyString(), anyString())).thenAnswer(call -> call.getArgument(1)); when(plugin.getVoteSiteManager().hasVoteSite(anyString())).thenReturn(true); + when(plugin.getVoteSiteManager().getVoteSite(anyString(), anyBoolean())) + .thenReturn(mock(com.bencodez.votingplugin.votesites.VoteSite.class)); events = mock(PluginManager.class); when(plugin.getServer().getPluginManager()).thenReturn(events); doAnswer(call -> { processed.add(call.getArgument(0)); return null; }).when(events).callEvent(any(PlayerVoteEvent.class)); serverData = plugin.getServerData(); @@ -62,6 +69,102 @@ private static ScheduledThreadPoolExecutor worker(VotifierVoteOverflowQueue queu private static void workerTurn(VotifierVoteOverflowQueue queue) throws Exception { worker(queue).submit(() -> {}).get(5, TimeUnit.SECONDS); } private void processingTurn() throws Exception { timer.submit(() -> {}).get(5, TimeUnit.SECONDS); } private List> disk() { return YamlConfiguration.loadConfiguration(directory.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); } + private record CapturedReceipt(UUID id, long time) { } + private CapturedReceipt retainedReceipt() throws Exception { + var field = VotiferEvent.class.getDeclaredField("captured"); field.setAccessible(true); + var entries = (Map) field.get(listener); assertEquals(1, entries.size()); + var vote = entries.values().iterator().next(); + var time = vote.getClass().getDeclaredField("occurredAt"); time.setAccessible(true); + return new CapturedReceipt((UUID) entries.keySet().iterator().next(), time.getLong(vote)); + } + private List recoverFullQueue() throws Exception { + var ids = new ArrayList(); + for (int index = 0; index < 256; index++) { + UUID id = UUID.randomUUID(); ids.add(id); + assertTrue(overflow.enqueue("Steve", SITE, 123L, id)); + } + overflow.close(); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + assertEquals(256, overflow.size()); + return ids; + } + @Test void saturatedCapturesUseAllIndependentOverflowSlotsWithoutChangingReceiptIdentity() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + timer.shutdownNow(); timer = new BoundedScheduledExecutor(1, 256); + when(plugin.getVoteTimer()).thenReturn(timer); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { + entered.countDown(); + if (!release.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("test processing timeout"); + return null; + }).when(serverData).addServiceSite(SITE); + var delivered = new CountDownLatch(512); + doAnswer(call -> { processed.add(call.getArgument(0)); delivered.countDown(); return null; }) + .when(events).callEvent(any(PlayerVoteEvent.class)); + try { + listener.onVotiferEvent(receipt()); assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int index = 1; index < 256; index++) listener.onVotiferEvent(receipt()); + assertEquals(256, listener.getPendingCaptureCount()); + long before = System.currentTimeMillis(); + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + long after = System.currentTimeMillis(); workerTurn(overflow); + assertEquals(256, overflow.size(), "Normal capture saturation must use the independent overflow capacity"); + assertEquals(256, listener.getPendingCaptureCount()); assertTrue(processed.isEmpty()); + listener.onVotiferEvent(receipt()); workerTurn(overflow); + assertEquals(256, overflow.size()); assertEquals(256, listener.getPendingCaptureCount()); + overflow.close(); var rows = disk(); assertEquals(256, rows.size()); + var occurrences = new HashMap(); + for (var row : rows) { + long time = ((Number) row.get("Time")).longValue(); assertTrue(time >= before && time <= after); + assertNull(occurrences.put(UUID.fromString((String) row.get("LocalOccurrenceId")), time)); + } + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); overflow.start(); + release.countDown(); assertTrue(delivered.await(10, TimeUnit.SECONDS)); processingTurn(); workerTurn(overflow); + assertEquals(512, processed.size()); assertEquals(512, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + for (var event : processed) if (occurrences.containsKey(event.getLocalOccurrenceId())) + assertEquals(occurrences.get(event.getLocalOccurrenceId()).longValue(), event.getCanonicalOccurrenceTime()); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); + } finally { release.countDown(); } + } + @Test void rejectedTransferRetriesAfterRecoveredFullQueueDrains() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var recovered = recoverFullQueue(); + var admissions = new AtomicInteger(); var wrapper = mock(ScheduledExecutorService.class); + doAnswer(call -> { + if (admissions.getAndIncrement() == 0) throw new RejectedExecutionException("initial admission rejected"); + return timer.submit(call.getArgument(0, Runnable.class)); + }).when(wrapper).submit(any(Runnable.class)); when(plugin.getVoteTimer()).thenReturn(wrapper); + var delivered = new CountDownLatch(257); + doAnswer(call -> { processed.add(call.getArgument(0)); delivered.countDown(); return null; }) + .when(events).callEvent(any(PlayerVoteEvent.class)); + listener.onVotiferEvent(receipt()); workerTurn(overflow); + var original = retainedReceipt(); assertEquals(256, overflow.size()); assertTrue(processed.isEmpty()); + overflow.start(); assertTrue(delivered.await(5, TimeUnit.SECONDS), "Rejected capture must retry when recovered entries drain"); + processingTurn(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); + assertEquals(257, processed.size()); assertEquals(257, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + assertTrue(processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).toList().containsAll(recovered)); + var retry = processed.stream().filter(event -> original.id().equals(event.getLocalOccurrenceId())).toList(); + assertEquals(1, retry.size()); assertEquals(original.time(), retry.getFirst().getCanonicalOccurrenceTime()); + } + @Test void stopRetriesRejectedTransferAfterCapacityReturnsWithExactReceipt() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var recovered = recoverFullQueue(); + var rejecting = mock(ScheduledExecutorService.class); + when(rejecting.submit(any(Runnable.class))).thenThrow(new RejectedExecutionException("vote worker full")); + when(plugin.getVoteTimer()).thenReturn(rejecting); + listener.onVotiferEvent(receipt()); workerTurn(overflow); var original = retainedReceipt(); + overflow.acknowledgeOccurrence(recovered.getFirst()); + listener.stop(); workerTurn(overflow); + assertEquals(0, listener.getPendingCaptureCount(), "Stop must retry a transfer previously rejected for capacity"); + assertEquals(256, overflow.size()); assertTrue(processed.isEmpty()); overflow.close(); + var rows = disk(); assertEquals(256, rows.size()); + var retained = rows.stream().filter(row -> original.id().toString().equals(row.get("LocalOccurrenceId"))).toList(); + assertEquals(1, retained.size()); assertEquals(original.time(), ((Number) retained.getFirst().get("Time")).longValue()); + assertTrue(rows.stream().noneMatch(row -> recovered.getFirst().toString().equals(row.get("LocalOccurrenceId")))); + } @Test void startedBlockedBeforeAcceptedPipelineSurvivesStopCancellationAndRestart() throws Exception { var entered = new CountDownLatch(1); var release = new CountDownLatch(1); doAnswer(call -> { diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 9593f0f9f1..79b35e2589 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -74,6 +74,8 @@ Recovered time-change replay is constructed and registered only after date defin Extension events dispatched from a Bukkit/Paper/Folia gameplay thread are admitted to the existing ordered storage worker (at most 64 pending). The asynchronous event flag is not treated as a thread check. Deferred proxy accounting remains unconfirmed/quarantined until reconciliation; submission alone never acknowledges durable accounting. Reload, disable, or overload can withhold such extension occurrences. Native VotingPlugin asynchronous ingress retains synchronous completion reporting. +When the normal capture registry is full, validated receipts use the independent overflow admission lane directly; they do not need another normal executor/capture slot. If both bounded owners cannot admit a new receipt, rejection is explicit. A rejected transfer of an already captured receipt clears its in-flight state and retries with the same UUID/time through one coalesced 250 ms wakeup on the existing overflow worker. That wakeup targets only captures which already requested transfer, preserving normal callback execution and the STARTED release fence; listener stop can also retry failed transfers. No additional executor or unbounded waiter list is created, and retry wakeups stop when the overflow retires. + Native queue retirement drains its accepted worker transfers and writes the final snapshot after loading, with a one-second lifecycle wait. A blocked worker or failed write is reported as incomplete publication, not a successful durable admission; sudden process death before asynchronous publication can still lose an unpublished receipt. This does not claim universal exactly-once normal rewards or uninterrupted receipt while bounded owners are full. A callback that outlives the bounded shutdown and completes only after the overflow seals its final snapshot remains an uncertain recovery case. The retired queue reports reconciliation is required and retains the occurrence rather than overwriting a successor queue. Partial ordinary side effects followed by an exception are also uncertain: recovery keeps the original UUID/time but can repeat ordinary effects under the existing at-least-once overflow contract. These boundaries do not weaken date-ledger identity, add a reward/accounting engine, or extend shutdown waits. From 42398de090ec7a8d26bba4e4db2cf8c439ba9015 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 12 Oct 2026 00:10:09 -0600 Subject: [PATCH 19/22] Retain overflow receipts until vote processing confirms completion --- .../votingplugin/VotingPluginMain.java | 4 +- .../votingplugin/listeners/VotiferEvent.java | 5 + .../listeners/VotifierVoteOverflowQueue.java | 101 +++++++- .../DateVotifierStartupCaptureTest.java | 42 ++++ .../VotifierOverflowCompletionTest.java | 223 ++++++++++++++++++ docs/date-vote-milestones.md | 2 + 6 files changed, 366 insertions(+), 11 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 33a13f34c8..67252ef4b5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -2063,8 +2063,8 @@ void registerEarlyVotifierIngress() { VotiferEvent listener = new VotiferEvent(this, () -> localVotifierIngressReady); // Publish the memory owner before capture. Its independent worker loads // disk state; vote-executor cancellation cannot discard queue setup. - votifierVoteOverflowQueue = VotifierVoteOverflowQueue.initializeAsync(this, - listener::processVote, () -> localVotifierIngressReady && !localVotifierIngressClosed); + votifierVoteOverflowQueue = VotifierVoteOverflowQueue.initializeConfirmedAsync(this, + listener::processVoteConfirmed, () -> localVotifierIngressReady && !localVotifierIngressClosed); localVotifierListener = listener; if (localVotifierIngressReady) votifierVoteOverflowQueue.start(); getServer().getPluginManager().registerEvents(listener, this); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index f8c6bfde96..3837b3968d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -143,6 +143,11 @@ public void processVote(String voteSite, String voteUsername, long occurredAt, U processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId); } + /** False retains overflow ownership; partial ordinary effects may still have occurred. */ + public boolean processVoteConfirmed(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { + return processCapturedVote(voteSite, voteUsername, occurredAt, localOccurrenceId); + } + private boolean processCapturedVote(String voteSite, String voteUsername, long occurredAt, UUID localOccurrenceId) { java.util.Objects.requireNonNull(localOccurrenceId, "localOccurrenceId"); try { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 59ec8c4357..c9297b505e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -47,7 +47,18 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { @FunctionalInterface public interface VoteProcessor { void process(String serviceSite, String username, long occurredAt); } @FunctionalInterface - public interface OccurrenceProcessor { void process(String serviceSite, String username, long occurredAt, UUID localOccurrenceId); } + public interface OccurrenceProcessor { + void process(String serviceSite, String username, long occurredAt, UUID localOccurrenceId); + /** Legacy callbacks confirm completion by returning normally. */ + default boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId) { + process(serviceSite, username, occurredAt, localOccurrenceId); + return true; + } + } + @FunctionalInterface + public interface ConfirmedOccurrenceProcessor { + boolean processConfirmed(String serviceSite, String username, long occurredAt, UUID localOccurrenceId); + } private final OccurrenceProcessor processor; private final java.util.function.BooleanSupplier processingAllowed; private final Path file; @@ -56,6 +67,7 @@ public interface OccurrenceProcessor { void process(String serviceSite, String u private final Object persistenceWriteLock = new Object(); private final ArrayDeque entries = new ArrayDeque<>(); private boolean drainScheduled; + private boolean drainRetryScheduled; private boolean captureRetryScheduled; private boolean persistenceScheduled; private boolean persistenceDirty; @@ -96,6 +108,20 @@ public static VotifierVoteOverflowQueue initializeAsync(VotingPluginMain plugin, return new VotifierVoteOverflowQueue(plugin, processor, processingAllowed, true); } + /** Named factory preserves existing void callback/lambda contracts without ambiguous overloads. */ + public static VotifierVoteOverflowQueue initializeConfirmedAsync(VotingPluginMain plugin, + ConfirmedOccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed) { + java.util.Objects.requireNonNull(processor, "processor"); + return initializeAsync(plugin, new OccurrenceProcessor() { + @Override public void process(String site, String user, long time, UUID id) { + processor.processConfirmed(site, user, time, id); + } + @Override public boolean processConfirmed(String site, String user, long time, UUID id) { + return processor.processConfirmed(site, user, time, id); + } + }, processingAllowed); + } + private VotifierVoteOverflowQueue(VotingPluginMain plugin, OccurrenceProcessor processor, java.util.function.BooleanSupplier processingAllowed, boolean asynchronousInitialization) { this.asynchronousInitialization = asynchronousInitialization; @@ -223,6 +249,12 @@ private void scheduleDrainLocked() { private void drain() { while (true) { + // A generation fence parks its receipts instead of repeatedly submitting + // callbacks which are forbidden to execute (including inline submitters). + if (!processingAllowed.getAsBoolean()) { + synchronized (lock) { drainScheduled = false; } + return; + } synchronized (lock) { if (closed || closing || !loaded || loadFailed) { drainScheduled = false; return; } if (durableVersion != stateVersion) { @@ -239,14 +271,23 @@ private void drain() { // Serialize admission with enqueue so the version proven durable // above cannot change in the gap before submit accepts this vote. plugin.getVoteTimer().submit(() -> { - if (!processingAllowed.getAsBoolean()) { - synchronized (lock) { pending.submitted = false; } - return; // Retired generation retains the durable entry for restart. - } + boolean attempted = false; + boolean completed = false; try { - processor.process(pending.serviceSite, pending.username, pending.time, pending.localOccurrenceId); + if (!processingAllowed.getAsBoolean()) return; + synchronized (lock) { + if (closed || closing || !entries.contains(pending)) return; + } + attempted = true; + completed = processor.processConfirmed(pending.serviceSite, pending.username, + pending.time, pending.localOccurrenceId) && !Thread.currentThread().isInterrupted(); + } catch (RuntimeException | Error failure) { + plugin.getLogger().warning("Queued Votifier processing failed; retained receipt for retry: " + + failure.getClass().getSimpleName()); + throw failure; } finally { - acknowledge(pending); + if (completed) acknowledge(pending); + else retainFailedAttempt(pending, attempted); } }); } catch (RejectedExecutionException rejected) { @@ -259,21 +300,62 @@ private void drain() { // The queue is closing; the persisted entry remains for restart. } return; + } catch (RuntimeException | Error failure) { + // An inline submitter can expose callback failure on this worker. + // Retain retry ownership without leaving drainScheduled stuck true. + drainScheduled = false; + retainFailedAttempt(pending, true); + throw failure; } } } } private PendingVote nextUnsubmittedLocked() { + long now = System.nanoTime(); + boolean retryWaiting = false; for (PendingVote pending : entries) { - if (!pending.submitted && pending.executionReleased.getAsBoolean()) return pending; + if (pending.submitted || !pending.executionReleased.getAsBoolean()) continue; + if (pending.retryNotBeforeNanos != 0 && now - pending.retryNotBeforeNanos < 0) { + retryWaiting = true; + continue; + } + return pending; } + if (retryWaiting) scheduleDrainRetryLocked(); return null; } + private void retainFailedAttempt(PendingVote pending, boolean attempted) { + synchronized (lock) { + pending.submitted = false; + if (closed || closing || !entries.contains(pending) || !attempted) return; + // Inline submitters must not retry this same receipt in the current drain. + pending.retryNotBeforeNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(RETRY_DELAY_MILLIS); + scheduleDrainRetryLocked(); + } + } + + private void scheduleDrainRetryLocked() { + if (closed || closing || !loaded || loadFailed || !started || drainRetryScheduled) return; + drainRetryScheduled = true; + try { + worker.schedule(() -> { + synchronized (lock) { + drainRetryScheduled = false; + scheduleDrainLocked(); + } + }, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + } catch (RejectedExecutionException rejected) { drainRetryScheduled = false; } + } + private void acknowledge(PendingVote pending) { synchronized (lock) { - entries.remove(pending); + if (closed) { + plugin.getLogger().warning("Completed Votifier callback outlived queue shutdown; retained occurrence requires reconciliation"); + return; + } + if (!entries.remove(pending)) return; stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -533,6 +615,7 @@ private static final class PendingVote { private final UUID localOccurrenceId; private final java.util.function.BooleanSupplier executionReleased; private boolean submitted; + private long retryNotBeforeNanos; private PendingVote(String username, String serviceSite, long time, UUID localOccurrenceId) { this(username, serviceSite, time, localOccurrenceId, () -> true); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java index bc4a1afb19..55957ecf08 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -402,6 +402,48 @@ private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Ex } } + @Test void productionOverflowRetainsStorageRejectionThenRetriesOriginalOccurrence() throws Exception { + try (var fixture = new Fixture(root, 1)) { + fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.open(); + var rejected = new CountDownLatch(1); + var permitStorage = new java.util.concurrent.atomic.AtomicBoolean(); + var serverData = fixture.plugin.getServerData(); + doAnswer(call -> { + if (!permitStorage.get()) { + rejected.countDown(); + throw new RejectedExecutionException("synthetic storage admission rejection"); + } + return null; + }).when(serverData).addServiceSite(SERVICE); + UUID original = UUID.fromString("00000000-0000-0000-0000-000000000019"); + long originalTime = java.time.Instant.parse("2026-10-01T00:00:00Z").toEpochMilli(); + var overflow = fixture.plugin.getVotifierVoteOverflowQueue(); + assertTrue(overflow.enqueue("Steve", SERVICE, originalTime, original)); + assertTrue(rejected.await(3, TimeUnit.SECONDS)); + fixture.timer.submit(() -> { }).get(3, TimeUnit.SECONDS); + assertEquals(1, overflow.size(), "known pre-dispatch failure must retain durable receipt ownership"); + assertTrue(fixture.accepted.isEmpty()); + verify(fixture.user, never()).playerVote(any(), anyBoolean(), anyBoolean()); + verify(fixture.user, never()).addPoints(); + var persisted = YamlConfiguration.loadConfiguration(root.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); + assertEquals(1, persisted.size()); + assertEquals(original.toString(), persisted.getFirst().get("LocalOccurrenceId")); + assertEquals(originalTime, ((Number) persisted.getFirst().get("Time")).longValue()); + + permitStorage.set(true); + fixture.received(); + assertEquals(0, overflow.size()); + assertEquals(1, fixture.accepted.size()); assertEquals(1, fixture.posted.size()); + assertEquals(original, fixture.accepted.getFirst().getLocalOccurrenceId()); + assertEquals(originalTime, fixture.accepted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(original, fixture.posted.getFirst().getVoteUUID()); + assertEquals(originalTime, fixture.posted.getFirst().getCanonicalOccurrenceTime()); + assertEquals(1, fixture.progress().votes()); + verify(fixture.user).playerVote(fixture.site, true, false); + verify(fixture.user).addPoints(); + } + } + @Test void admittedOverflowCallbackCannotRewardAfterStartupGenerationRetires() throws Exception { try (var fixture = new Fixture(root, 0)) { fixture.plugin.registerEarlyVotifierIngress(); fixture.drain(); fixture.vote(); fixture.drain(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java new file mode 100644 index 0000000000..a60767c122 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierOverflowCompletionTest.java @@ -0,0 +1,223 @@ +package com.bencodez.votingplugin.tests.listeners; + +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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +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.listeners.VotifierVoteOverflowQueue; + +class VotifierOverflowCompletionTest { + private static final UUID OCCURRENCE = UUID.fromString("00000000-0000-0000-0000-000000000019"); + private static final long OCCURRED_AT = 123L; + private static final String SERVICE = "example.org"; + + private VotingPluginMain plugin(Path directory, ScheduledExecutorService timer) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getVoteTimer()).thenReturn(timer); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getOptions().getBedrockPlayerPrefix()).thenReturn("."); + return plugin; + } + + private ScheduledExecutorService inlineTimer() { + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return CompletableFuture.completedFuture(null); + }).when(timer).submit(any(Runnable.class)); + return timer; + } + + private ScheduledExecutorService capturedTimer(BlockingQueue tasks) { + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return CompletableFuture.completedFuture(null); + }).when(timer).submit(any(Runnable.class)); + return timer; + } + + private void enqueue(VotifierVoteOverflowQueue queue) throws Exception { + var admitted = new CompletableFuture(); + queue.enqueueAfterInitialization("Steve", SERVICE, OCCURRED_AT, OCCURRENCE, admitted::complete); + assertTrue(admitted.get(3, TimeUnit.SECONDS)); + } + + private List> rows(Path directory) { + return new ArrayList<>(YamlConfiguration.loadConfiguration(directory.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes")); + } + + private Runnable take(BlockingQueue tasks) throws Exception { + Runnable task = tasks.poll(3, TimeUnit.SECONDS); + assertNotNull(task, "overflow did not admit a pending receipt"); + return task; + } + + @Test void inlineFailureRetriesAfterDelayWithSameIdentityAndAcknowledgesOnlyCompletion(@TempDir Path directory) throws Exception { + AtomicInteger attempts = new AtomicInteger(); + AtomicInteger prefixEffects = new AtomicInteger(); + AtomicLong firstAttempt = new AtomicLong(); + CompletableFuture retried = new CompletableFuture<>(); + VotingPluginMain plugin = plugin(directory, inlineTimer()); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + assertEquals(SERVICE, site); assertEquals("Steve", user); + assertEquals(OCCURRED_AT, time); assertEquals(OCCURRENCE, id); + prefixEffects.incrementAndGet(); // Ordinary effects can precede an uncertain failure. + if (attempts.incrementAndGet() == 1) { firstAttempt.set(System.nanoTime()); return false; } + retried.complete(System.nanoTime() - firstAttempt.get()); + return true; + }, () -> true)) { + enqueue(queue); queue.start(); + assertTrue(retried.get(3, TimeUnit.SECONDS) >= TimeUnit.MILLISECONDS.toNanos(250), + "inline failure cannot retry in the same drain or bypass its backoff"); + queue.close(); + assertEquals(2, attempts.get()); + assertEquals(2, prefixEffects.get(), "the existing at-least-once contract does not roll back prefix effects"); + assertTrue(rows(directory).isEmpty()); + } + } + + @Test void legacyOccurrenceCallbackExceptionRetainsReceiptAndDoesNotStrandInlineDrain(@TempDir Path directory) throws Exception { + AtomicInteger attempts = new AtomicInteger(); + var completed = new CompletableFuture(); + VotingPluginMain plugin = plugin(directory, inlineTimer()); + try (var queue = new VotifierVoteOverflowQueue(plugin, (site, user, time, id) -> { + assertEquals(OCCURRED_AT, time); assertEquals(OCCURRENCE, id); + if (attempts.incrementAndGet() == 1) throw new IllegalStateException("synthetic callback failure"); + completed.complete(null); + }, () -> true)) { + assertTrue(queue.enqueue("Steve", SERVICE, OCCURRED_AT, OCCURRENCE)); queue.start(); + completed.get(3, TimeUnit.SECONDS); + queue.close(); + assertEquals(2, attempts.get()); + assertTrue(rows(directory).isEmpty()); + } + } + + @Test void failedConfirmedAttemptSurvivesRestartWithOriginalIdentity(@TempDir Path directory) throws Exception { + for (boolean exceptional : List.of(false, true)) { + Path data = directory.resolve(exceptional ? "exception" : "unconfirmed"); + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(data, capturedTimer(tasks)); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + if (exceptional) throw new IllegalStateException("synthetic callback failure"); + return false; + }, () -> true)) { + enqueue(queue); queue.start(); Runnable attempt = take(tasks); + if (exceptional) assertThrows(IllegalStateException.class, attempt::run); else attempt.run(); + assertEquals(1, queue.size()); + } + assertEquals(OCCURRENCE.toString(), rows(data).getFirst().get("LocalOccurrenceId")); + assertEquals(OCCURRED_AT, ((Number) rows(data).getFirst().get("Time")).longValue()); + tasks.clear(); + AtomicInteger processed = new AtomicInteger(); + try (var restarted = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + assertEquals(OCCURRED_AT, time); assertEquals(OCCURRENCE, id); processed.incrementAndGet(); return true; + }, () -> true)) { + restarted.start(); take(tasks).run(); + assertEquals(0, restarted.size()); + } + assertEquals(1, processed.get()); assertTrue(rows(data).isEmpty()); + } + } + + @Test void interruptedReturnDoesNotConfirmReceiptCompletion(@TempDir Path directory) throws Exception { + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(directory, capturedTimer(tasks)); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, (site, user, time, id) -> { + Thread.currentThread().interrupt(); return true; + }, () -> true)) { + enqueue(queue); queue.start(); + try { + take(tasks).run(); + assertTrue(Thread.currentThread().isInterrupted()); + assertEquals(1, queue.size(), "interrupted completion retains the uncertain receipt"); + } finally { Thread.interrupted(); } + } + assertEquals(OCCURRENCE.toString(), rows(directory).getFirst().get("LocalOccurrenceId")); + } + + @Test void callbackAdmittedBeforeCloseCannotRunOrOverwriteSuccessorQueue(@TempDir Path directory) throws Exception { + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(directory, capturedTimer(tasks)); + AtomicInteger processed = new AtomicInteger(); + Runnable stale; + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, + (site, user, time, id) -> { processed.incrementAndGet(); return true; }, () -> true)) { + enqueue(queue); queue.start(); stale = take(tasks); + } + try (var successor = new VotifierVoteOverflowQueue(plugin, (site, user) -> { })) { + assertTrue(successor.enqueue("Alex", "second.example.org", 456L)); + stale.run(); assertEquals(0, processed.get()); assertEquals(2, successor.size()); + } + assertEquals(2, rows(directory).size()); + assertEquals(OCCURRENCE.toString(), rows(directory).getFirst().get("LocalOccurrenceId")); + } + + @Test void retiredGenerationRetainsAdmittedReceiptWithoutInvokingProcessor(@TempDir Path directory) throws Exception { + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(directory, capturedTimer(tasks)); + var active = new java.util.concurrent.atomic.AtomicBoolean(true); + AtomicInteger processed = new AtomicInteger(); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, + (site, user, time, id) -> { processed.incrementAndGet(); return true; }, active::get)) { + enqueue(queue); queue.start(); Runnable admitted = take(tasks); + active.set(false); admitted.run(); + assertEquals(0, processed.get()); assertEquals(1, queue.size()); + assertTrue(tasks.isEmpty(), "a retired callback must not schedule an immediate retry"); + } + assertEquals(OCCURRENCE.toString(), rows(directory).getFirst().get("LocalOccurrenceId")); + } + + @Test void retiredInlineGenerationParksBeforeSubmittingCallbacks(@TempDir Path directory) throws Exception { + ScheduledExecutorService timer = inlineTimer(); + VotingPluginMain plugin = plugin(directory, timer); + var checked = new CompletableFuture(); + try (var queue = VotifierVoteOverflowQueue.initializeConfirmedAsync(plugin, + (site, user, time, id) -> { fail("retired generation cannot process receipts"); return true; }, + () -> { checked.complete(null); return false; })) { + enqueue(queue); queue.start(); checked.get(3, TimeUnit.SECONDS); + assertEquals(1, queue.size()); + verify(timer, never()).submit(any(Runnable.class)); + } + assertEquals(1, rows(directory).size()); + } + + @Test void legacyTwoAndThreeArgumentCallbacksStillCompleteNormally(@TempDir Path directory) throws Exception { + for (int arity : List.of(2, 3)) { + Path data = directory.resolve("callback-" + arity); + BlockingQueue tasks = new LinkedBlockingQueue<>(); + VotingPluginMain plugin = plugin(data, capturedTimer(tasks)); + AtomicInteger processed = new AtomicInteger(); + VotifierVoteOverflowQueue queue = arity == 2 + ? new VotifierVoteOverflowQueue(plugin, (site, user) -> processed.incrementAndGet()) + : new VotifierVoteOverflowQueue(plugin, (site, user, time) -> { + assertEquals(OCCURRED_AT, time); processed.incrementAndGet(); + }, () -> true); + try (queue) { + assertTrue(queue.enqueue("Steve", SERVICE, OCCURRED_AT, OCCURRENCE)); + queue.start(); take(tasks).run(); assertEquals(0, queue.size()); + } + assertEquals(1, processed.get()); assertTrue(rows(data).isEmpty()); + } + } +} diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 79b35e2589..ca0eed6fcf 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -79,3 +79,5 @@ When the normal capture registry is full, validated receipts use the independent Native queue retirement drains its accepted worker transfers and writes the final snapshot after loading, with a one-second lifecycle wait. A blocked worker or failed write is reported as incomplete publication, not a successful durable admission; sudden process death before asynchronous publication can still lose an unpublished receipt. This does not claim universal exactly-once normal rewards or uninterrupted receipt while bounded owners are full. A callback that outlives the bounded shutdown and completes only after the overflow seals its final snapshot remains an uncertain recovery case. The retired queue reports reconciliation is required and retains the occurrence rather than overwriting a successor queue. Partial ordinary side effects followed by an exception are also uncertain: recovery keeps the original UUID/time but can repeat ordinary effects under the existing at-least-once overflow contract. These boundaries do not weaken date-ledger identity, add a reward/accounting engine, or extend shutdown waits. + +Overflow replay removes a receipt only after the production listener reports successful, non-interrupted processing. A known failure or callback exception retains its original occurrence UUID/time and retries through one coalesced 250 ms wakeup on the existing overflow worker. Individual failed entries cannot retry immediately in the same drain; other eligible entries can continue. Retired generations retain their receipts without running the listener, and callbacks finishing after the final snapshot cannot overwrite successor state. Existing public void callbacks retain normal-return completion semantics. A failure before event dispatch causes no normal vote effects; failure after a prefix of ordinary effects remains the documented at-least-once uncertainty above. From 4aa8f3cd85e44b7a6698d36a6e9b51dd1927c202 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 05:55:24 -0600 Subject: [PATCH 20/22] Retain captured Votifier votes in bounded shutdown snapshots --- .../votingplugin/listeners/VotiferEvent.java | 71 +++++- .../listeners/VotifierVoteOverflowQueue.java | 214 ++++++++++++++++-- .../DateVotifierStartupCaptureTest.java | 5 +- .../VotiferEventDisabledVoteSiteTest.java | 10 +- .../VotifierStartedCaptureOwnershipTest.java | 138 ++++++++++- .../VotifierVoteOverflowQueueTest.java | 2 +- docs/date-vote-milestones.md | 25 +- 7 files changed, 419 insertions(+), 46 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 32e207c45e..9c75b7ee55 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -23,7 +23,8 @@ public class VotiferEvent implements Listener { private final VotingPluginMain plugin; private final java.util.function.BooleanSupplier ready; private volatile boolean accepting = true; - private static final int MAX_CAPTURED = 256; + private static final int MAX_CAPTURED = VotifierVoteOverflowQueue.MAX_CAPTURED; + private boolean retirementQueued; private final Object captureLock = new Object(); // Receipt ownership only. Entries leave at completed processing or confirmed // overflow transfer; STARTED work remains owned until then. @@ -54,7 +55,18 @@ public VotiferEvent(VotingPluginMain plugin, java.util.function.BooleanSupplier public void stop() { synchronized (captureLock) { accepting = false; - for (CapturedVote vote : java.util.List.copyOf(captured.values())) transferLocked(vote); + if (retirementQueued) return; + retirementQueued = true; + var overflow = plugin.getVotifierVoteOverflowQueue(); + if (overflow == null) { + for (CapturedVote vote : java.util.List.copyOf(captured.values())) transferLocked(vote); + return; + } + var snapshot = captured.values().stream().map(vote -> new VotifierVoteOverflowQueue.RetiringCapture( + vote.username, vote.site, vote.occurredAt, vote.id, + () -> !vote.started || vote.finished && !vote.completed, () -> vote.completed, + admitted -> transferred(vote, admitted))).toList(); + overflow.retireCaptures(snapshot); } } @@ -65,7 +77,10 @@ public int getPendingCaptureCount() { private void runCaptured(CapturedVote vote) { synchronized (captureLock) { if (!captured.containsKey(vote.id) || vote.transferring || vote.started) return; - if (!accepting || !ready.getAsBoolean()) { transferLocked(vote); return; } + if (!accepting || !ready.getAsBoolean()) { + if (accepting || !retirementQueued) transferLocked(vote); + return; + } vote.started = true; } boolean completed = false; @@ -81,6 +96,7 @@ private void runCaptured(CapturedVote vote) { if (vote.transferred) acknowledge = vote.recoveryOwner; } } + if (completed && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id); if (acknowledge != null) acknowledge.acknowledgeOccurrence(vote.id); else if (vote.transferred && vote.recoveryOwner != null) vote.recoveryOwner.resumeTransfers(); } @@ -89,7 +105,8 @@ private void runCaptured(CapturedVote vote) { private void transferLocked(CapturedVote vote) { if (vote.transferring) return; vote.transferring = true; - VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); + VotifierVoteOverflowQueue overflow = vote.recoveryOwner != null + ? vote.recoveryOwner : plugin.getVotifierVoteOverflowQueue(); vote.recoveryOwner = overflow; if (overflow == null) { transferred(vote, false); return; } overflow.enqueueAfterInitialization(vote.username, vote.site, vote.occurredAt, vote.id, @@ -100,6 +117,7 @@ private void transferLocked(CapturedVote vote) { private void transferred(CapturedVote vote, boolean admitted) { boolean completed, retry, reportFailure; synchronized (captureLock) { + if (vote.transferred && !admitted) return; // Older failure cannot revoke confirmed queue ownership. vote.transferred = admitted; completed = vote.completed; if (!admitted) vote.transferring = false; if (admitted || completed) captured.remove(vote.id); @@ -121,7 +139,7 @@ private void retryCapturedTransfers() { for (CapturedVote vote : java.util.List.copyOf(captured.values())) { // Ordinary queued/running callbacks retain their original execution. // Only receipts which already attempted a failed handoff need retry. - if (vote.recoveryOwner != null && !vote.transferring) transferLocked(vote); + if (vote.transferFailureReported && !vote.transferring) transferLocked(vote); } } } @@ -260,20 +278,51 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("IP: " + IP); CapturedVote capturedVote = new CapturedVote(voteUsername, voteSite, occurredAt, localOccurrenceId); + VotifierVoteOverflowQueue.CaptureAdmission admission; synchronized (captureLock) { if (!accepting) return; + var overflow = plugin.getVotifierVoteOverflowQueue(); + capturedVote.recoveryOwner = overflow; if (captured.size() >= MAX_CAPTURED) { - // The overflow owns a separate bounded admission lane; do not - // require another normal capture/executor slot to reach it. + // The overflow owns its independent bounded admission lane. transferLocked(capturedVote); return; } + admission = overflow == null ? VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED + : overflow.reserveCapture(localOccurrenceId); + if (admission == VotifierVoteOverflowQueue.CaptureAdmission.REJECTED) { + plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted"); + return; + } + // Reserve known capacity and publish capture ownership together with + // respect to stop's snapshot. Unknown capacity remains provisional. captured.put(localOccurrenceId, capturedVote); } - try { - plugin.getVoteTimer().submit(() -> runCaptured(capturedVote)); - } catch (RejectedExecutionException rejected) { - synchronized (captureLock) { transferLocked(capturedVote); } + if (admission == VotifierVoteOverflowQueue.CaptureAdmission.WAITING_FOR_LOAD) { + capturedVote.recoveryOwner.reserveCaptureAfterInitialization(localOccurrenceId, + admitted -> admitCaptured(capturedVote, admitted)); + } else admitCaptured(capturedVote, true); + } + + private void admitCaptured(CapturedVote vote, boolean admitted) { + synchronized (captureLock) { + if (captured.get(vote.id) != vote) { + if (admitted && vote.recoveryOwner != null) vote.recoveryOwner.releaseCapture(vote.id); + return; + } + if (!admitted) { + captured.remove(vote.id); + plugin.getLogger().severe("Votifier capture capacity unavailable; vote was not admitted"); + return; + } + if (!accepting) { + if (!retirementQueued) transferLocked(vote); + return; + } + } + try { plugin.getVoteTimer().submit(() -> runCaptured(vote)); } + catch (RejectedExecutionException rejected) { + synchronized (captureLock) { transferLocked(vote); } } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 032810c4a0..a5c0f688ff 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -45,6 +45,8 @@ public final class VotifierVoteOverflowQueue implements AutoCloseable { /* Keep the overflow bounded independently of the executor's 256 admissions. */ private static final int MAX_ENTRIES = 256; + public static final int MAX_CAPTURED = 256; + private static final int MAX_RETAINED_ENTRIES = MAX_ENTRIES + MAX_CAPTURED; private static final long MAX_FILE_BYTES = 4L * 1024L * 1024L; private static final long RETRY_DELAY_MILLIS = 250L; private static final String QUEUE_FILE = "VotifierVoteQueue.yml"; @@ -89,9 +91,13 @@ public interface ConfirmedOccurrenceProcessor { private boolean started; private boolean closed; private boolean closing; + private final java.util.concurrent.CompletableFuture closePublication = new java.util.concurrent.CompletableFuture<>(); + private boolean closeFailureReported; private volatile boolean loaded; private volatile boolean loadFailed; private int pendingIngress; + private int pendingCaptureAdmissions; + private final Set captureReservations = new java.util.HashSet<>(); private final boolean asynchronousInitialization; private long stateVersion; private long durableVersion; @@ -224,8 +230,17 @@ private boolean enqueueStored(String username, String serviceSite, long occurred boolean admittedTransfer, java.util.function.BooleanSupplier executionReleased) { if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) return false; synchronized (lock) { - if (closed || closing && !admittedTransfer || !loaded || loadFailed || entries.size() >= MAX_ENTRIES) return false; - entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased)); + if (closed || closing && !admittedTransfer || !loaded || loadFailed) return false; + var existing = occurrence(localOccurrenceId); + if (existing != null) { + boolean same = matches(existing, username, serviceSite, occurredAt); + if (same) captureReservations.remove(localOccurrenceId); + return same; + } + if (entries.size() >= MAX_ENTRIES || !captureReservations.contains(localOccurrenceId) + && entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) return false; + entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased)); + captureReservations.remove(localOccurrenceId); stateVersion++; requestPersistenceLocked(); scheduleDrainLocked(); @@ -233,6 +248,127 @@ private boolean enqueueStored(String username, String serviceSite, long occurred } } + public enum CaptureAdmission { ACCEPTED, WAITING_FOR_LOAD, REJECTED } + + /** Reserve final-snapshot capacity before the normal executor owns this receipt. */ + public CaptureAdmission reserveCapture(UUID id) { + java.util.Objects.requireNonNull(id); + synchronized (lock) { + if (closed || closing || loadFailed) return CaptureAdmission.REJECTED; + if (!loaded) return CaptureAdmission.WAITING_FOR_LOAD; + if (captureReservations.contains(id)) return CaptureAdmission.ACCEPTED; + if (captureReservations.size() >= MAX_CAPTURED + || entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) + return CaptureAdmission.REJECTED; + captureReservations.add(id); + return CaptureAdmission.ACCEPTED; + } + } + + /** Unknown disk capacity is provisional; keep at most one deferred admission per capture. */ + public void reserveCaptureAfterInitialization(UUID id, Consumer completion) { + java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(completion); + boolean admitted; + synchronized (lock) { + admitted = !closed && !closing && pendingCaptureAdmissions < MAX_CAPTURED; + if (admitted) pendingCaptureAdmissions++; + } + if (!admitted) { completion.accept(false); return; } + var finished = new java.util.concurrent.atomic.AtomicBoolean(); + class Admission implements Runnable { + void finish(boolean accepted) { + if (!finished.compareAndSet(false, true)) return; + synchronized (lock) { pendingCaptureAdmissions--; } + completion.accept(accepted); + } + @Override public void run() { + var result = reserveCapture(id); + if (result != CaptureAdmission.WAITING_FOR_LOAD) { + finish(result == CaptureAdmission.ACCEPTED); + return; + } + try { worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); } + catch (RejectedExecutionException rejected) { finish(false); } + } + } + var admission = new Admission(); + try { worker.execute(admission); } + catch (RejectedExecutionException rejected) { admission.finish(false); } + } + + public void releaseCapture(UUID id) { + synchronized (lock) { captureReservations.remove(id); } + } + + public record RetiringCapture(String username, String serviceSite, long time, UUID id, + java.util.function.BooleanSupplier executionReleased, + java.util.function.BooleanSupplier completed, Consumer completion) { + public RetiringCapture { + java.util.Objects.requireNonNull(username); java.util.Objects.requireNonNull(serviceSite); + java.util.Objects.requireNonNull(id); java.util.Objects.requireNonNull(executionReleased); + java.util.Objects.requireNonNull(completed); java.util.Objects.requireNonNull(completion); + if (time <= 0) throw new IllegalArgumentException("capture receipt time must be positive"); + } + } + + /** One FIFO batch, before close's final snapshot; normal overflow admission stays capped at256. */ + public void retireCaptures(List captures) { + var snapshot = List.copyOf(captures); + if (snapshot.size() > MAX_CAPTURED) throw new IllegalArgumentException("capture retirement exceeds capacity"); + Runnable transfer = () -> { + boolean[] results = new boolean[snapshot.size()]; + for (int index = 0; index < snapshot.size(); index++) { + var capture = snapshot.get(index); + boolean accepted = false; + synchronized (lock) { + if (!closed && loaded && !loadFailed) { + var existing = occurrence(capture.id()); + if (capture.completed().getAsBoolean()) { + captureReservations.remove(capture.id()); + accepted = true; + } else if (existing != null) { + accepted = matches(existing, capture.username(), capture.serviceSite(), capture.time()); + if (accepted) captureReservations.remove(capture.id()); + } else if (entries.size() < MAX_RETAINED_ENTRIES + && (captureReservations.contains(capture.id()) + || entries.size() + captureReservations.size() < MAX_RETAINED_ENTRIES)) { + entries.addLast(new PendingVote(capture.username(), capture.serviceSite(), capture.time(), + capture.id(), capture.executionReleased())); + captureReservations.remove(capture.id()); + stateVersion++; requestPersistenceLocked(); + accepted = true; + } + } + } + results[index] = accepted; + } + for (int index = 0; index < snapshot.size(); index++) { + try { snapshot.get(index).completion().accept(results[index]); } + catch (RuntimeException | Error failure) { + try { plugin.getLogger().warning("Votifier retirement callback failed; queue ownership is retained"); } + catch (RuntimeException | Error ignored) { } + } + } + }; + boolean submitted = false; + synchronized (lock) { + if (!closed && !closing) { + try { worker.execute(transfer); submitted = true; } + catch (RejectedExecutionException rejected) { } + } + } + if (!submitted) for (var capture : snapshot) capture.completion().accept(false); + } + + private PendingVote occurrence(UUID id) { + for (var vote : entries) if (vote.localOccurrenceId.equals(id)) return vote; + return null; + } + + private static boolean matches(PendingVote vote, String username, String site, long time) { + return vote.username.equals(username) && vote.serviceSite.equals(site) && vote.time == time; + } + /** Completion only; transfers separately report load/capacity admission failure. */ public boolean isInitializationComplete() { return loaded; } @@ -464,9 +600,9 @@ private void persistLoop() { private void load() { ArrayDeque recovered = new ArrayDeque<>(); try { - synchronized (lock) { if (closed || closing) return; } + synchronized (lock) { if (closed) return; } if (!acquireOwner()) { - if (asynchronousInitialization) { + if (asynchronousInitialization && !closing) { worker.schedule(this::load, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); return; } @@ -477,7 +613,7 @@ private void load() { yaml.loadFromString(readQueueFile()); Object raw = yaml.get("Votes"); if (!(raw instanceof List values)) throw new IOException("queue votes are not a list"); - if (values.size() > MAX_ENTRIES) throw new IOException("queue exceeds entry capacity"); + if (values.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue exceeds entry capacity"); boolean skipped = false; for (Object value : values) { if (!(value instanceof Map map)) { @@ -615,6 +751,7 @@ private void writeSnapshot(List snapshot) throws IOException { private void writeSnapshotLocked(List snapshot) throws IOException { if (ownerLock == null || !ownerLock.isValid()) throw new IOException("queue file ownership is unavailable"); + if (snapshot.size() > MAX_RETAINED_ENTRIES) throw new IOException("queue snapshot exceeds entry capacity"); YamlConfiguration yaml = new YamlConfiguration(); List> values = new ArrayList<>(); for (PendingVote pending : snapshot) { @@ -678,28 +815,55 @@ public void close() { private void closeInitializedWorker() { synchronized (lock) { - if (closed || closing) return; - closing = true; - // FIFO finalization follows loading and every accepted ingress transfer. - // Never replace an unreadable disk queue with a guessed empty snapshot. - worker.execute(() -> { - List snapshot; - synchronized (lock) { closed = true; snapshot = new ArrayList<>(entries); } - if (loadFailed) { releaseOwner(); return; } - try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } } - catch (IOException failure) { - plugin.getLogger().warning("Unable to persist queued Votifier votes during shutdown: " - + failure.getClass().getSimpleName()); - } - releaseOwner(); - }); - worker.shutdown(); - } - try { - if (!worker.awaitTermination(1, TimeUnit.SECONDS)) { - plugin.getLogger().warning("Votifier queue shutdown publication is still pending on its worker"); + if (!closing && !closed) { + closing = true; + // Keep the existing worker and file owner through failed final publication. + worker.execute(this::finishClosePublication); } + } + try { closePublication.get(1, TimeUnit.SECONDS); } + catch (java.util.concurrent.TimeoutException pending) { + plugin.getLogger().warning("Votifier queue shutdown publication is still pending on its worker; recovery is unconfirmed"); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + catch (java.util.concurrent.ExecutionException failure) { + throw new IllegalStateException("Votifier queue shutdown publication failed", failure.getCause()); + } + } + + private void finishClosePublication() { + List snapshot; + long version; + synchronized (lock) { + if (closed) return; + snapshot = new ArrayList<>(entries); + version = stateVersion; + } + if (loadFailed || !loaded) { + // Unreadable/unowned storage is unknown; never replace it with an empty guess. + synchronized (lock) { closed = true; } + releaseOwner(); worker.shutdown(); closePublication.complete(null); + return; + } + try { synchronized (persistenceWriteLock) { writeSnapshotLocked(snapshot); } } + catch (IOException failure) { + if (!closeFailureReported) { + closeFailureReported = true; + try { plugin.getLogger().warning("Unable to publish final Votifier queue; its worker retains ownership for retry"); } + catch (RuntimeException | Error ignored) { } + } + worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + synchronized (lock) { + // A confirmed original callback may have removed a receipt during IO. + if (version != stateVersion) { + worker.execute(this::finishClosePublication); + return; + } + closed = true; + durableVersion = version; + } + releaseOwner(); worker.shutdown(); closePublication.complete(null); } private static final class PendingVote { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java index 55957ecf08..58f0d74904 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/DateVotifierStartupCaptureTest.java @@ -316,7 +316,10 @@ private void capturedAdmissionSurvivesCancellation(boolean readyFirst) throws Ex originalTime = captureTime.getLong(registry.values().iterator().next()); try { fixture.capture().stop(); - assertFalse(fixture.timer.shutdownNow().isEmpty(), "the capture callback never executed"); + var cancelled = fixture.timer.shutdownNow(); + if (readyFirst) assertFalse(cancelled.isEmpty(), "the admitted capture callback never executed"); + // Before queue initialization, the receipt is provisionally owned + // by ingress; retirement must save it without executor admission. } finally { release.countDown(); } assertTrue(fixture.timer.awaitTermination(3, TimeUnit.SECONDS)); fixture.plugin.getVotifierVoteOverflowQueue().close(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index 44ed83422a..f51034e0a3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.tests.listeners; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.doAnswer; @@ -66,6 +67,8 @@ public void setUp() { when(plugin.getVoteSiteManager()).thenReturn(voteSiteManager); when(plugin.getVoteTimer()).thenReturn(voteTimer); when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflowQueue); + when(overflowQueue.reserveCapture(any(java.util.UUID.class))) + .thenReturn(VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED); when(plugin.getServer()).thenReturn(server); when(server.getPluginManager()).thenReturn(pluginManager); @@ -169,12 +172,17 @@ public void cancelledCapturesAreBoundedAndTransferredOnceBeforeShutdown() { // The 257th receipt uses the independently bounded overflow directly. verify(overflowQueue).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); + doAnswer(call -> { + var captures = (java.util.List) call.getArgument(0); + captures.forEach(capture -> capture.completion().accept(true)); return null; + }).when(overflowQueue).retireCaptures(anyList()); listener.stop(); listener.stop(); org.junit.jupiter.api.Assertions.assertEquals(0, listener.getPendingCaptureCount()); // Cancelled callbacks that later run cannot transfer or process again. tasks.forEach(Runnable::run); - verify(overflowQueue, org.mockito.Mockito.times(257)).enqueueAfterInitialization(anyString(), anyString(), + verify(overflowQueue, org.mockito.Mockito.times(1)).enqueueAfterInitialization(anyString(), anyString(), org.mockito.ArgumentMatchers.anyLong(), any(java.util.UUID.class), any(java.util.function.BooleanSupplier.class), any()); + verify(overflowQueue).retireCaptures(anyList()); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java index 05251b4baf..49d8f38efd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java @@ -338,7 +338,137 @@ private void completedCurrentCallbackReconcilesExactTransferredOccurrence(String assertTrue(delivered.await(5, TimeUnit.SECONDS)); processingTurn(); workerTurn(overflow); assertEquals(1, processed.size()); assertEquals(0, listener.getPendingCaptureCount()); assertEquals(0, overflow.size()); } - @Test void failedFullQueueTransferRetainsStartedOwnershipAndExactCapacity() throws Exception { + @Test void shutdownRetainsAnUnstartedCaptureBesideAFullRecoveredQueueAndRestartsItsExactOccurrence() throws Exception { + var recovered = recoverFullQueue(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + timer.submit(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + listener.onVotiferEvent(receipt()); + var original = retainedReceipt(); + try { + ready.set(false); listener.stop(); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + workerTurn(overflow); overflow.close(); + var rows = disk(); + assertEquals(257, rows.size(), "Closing a full overflow must retain its accepted normal capture too"); + assertTrue(rows.stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))).toList() + .containsAll(recovered)); + var retained = rows.stream().filter(row -> original.id().toString().equals(row.get("LocalOccurrenceId"))).toList(); + assertEquals(1, retained.size()); assertEquals(original.time(), ((Number) retained.getFirst().get("Time")).longValue()); + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + ready.set(true); + var delivered = new CountDownLatch(257); + doAnswer(call -> { + var event = call.getArgument(0, PlayerVoteEvent.class); + event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; + }).when(events).callEvent(any(PlayerVoteEvent.class)); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + overflow.start(); assertTrue(delivered.await(10, TimeUnit.SECONDS)); + processingTurn(); workerTurn(overflow); + assertEquals(257, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + var replay = processed.stream().filter(event -> original.id().equals(event.getLocalOccurrenceId())).toList(); + assertEquals(1, replay.size()); assertEquals(original.time(), replay.getFirst().getCanonicalOccurrenceTime()); + } finally { release.countDown(); } + } + + @Test void failedFinalWriteRetainsOwnershipUntilRepairAndPreservesTheCaptureForASuccessor() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + recoverFullQueue(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + timer.submit(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + listener.onVotiferEvent(receipt()); var original = retainedReceipt(); + Path target = directory.resolve("VotifierVoteQueue.yml"); + Files.delete(target); Files.createDirectory(target); + Path blocker = target.resolve("synthetic-blocker"); Files.writeString(blocker, "synthetic"); + VotifierVoteOverflowQueue successor = null; + try { + ready.set(false); listener.stop(); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + overflow.close(); + successor = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> {}, () -> false); + workerTurn(successor); + assertFalse(successor.isInitializationComplete(), "successor cannot initialize while final publication is uncertain"); + Files.delete(blocker); Files.delete(target); + overflow.close(); assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!successor.isInitializationComplete() && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(successor.isInitializationComplete()); assertEquals(257, successor.size()); + var rows = disk(); assertEquals(257, rows.size()); + var row = rows.stream().filter(value -> original.id().toString().equals(value.get("LocalOccurrenceId"))).toList(); + assertEquals(1, row.size()); assertEquals(original.time(), ((Number) row.getFirst().get("Time")).longValue()); + } finally { + release.countDown(); + if (Files.isDirectory(target)) { Files.deleteIfExists(blocker); Files.delete(target); } + overflow.close(); + if (successor != null) successor.close(); + } + } + + @Test void throwingRetirementCallbackDoesNotPreventTheRestOfTheBatchBeingRetained() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + assertEquals(VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED, overflow.reserveCapture(first)); + assertEquals(VotifierVoteOverflowQueue.CaptureAdmission.ACCEPTED, overflow.reserveCapture(second)); + var completed = new CountDownLatch(1); + overflow.retireCaptures(List.of( + new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 123L, first, () -> true, () -> false, + accepted -> { throw new IllegalStateException("synthetic callback failure"); }), + new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 456L, second, () -> true, () -> false, + accepted -> { assertTrue(accepted); completed.countDown(); }))); + assertTrue(completed.await(5, TimeUnit.SECONDS)); + overflow.close(); + assertEquals(Set.of(first, second), disk().stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + } + + @Test void combinedFullSnapshotRemainsBoundedAcrossRepeatedRecoveryAndFreshIngress() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var recovered = recoverFullQueue(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + timer.submit(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + assertEquals(256, listener.getPendingCaptureCount()); + try { + ready.set(false); listener.stop(); listener.stop(); + timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); + workerTurn(overflow); overflow.close(); + var rows = disk(); assertEquals(512, rows.size()); + var ids = rows.stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet()); + assertEquals(512, ids.size()); assertTrue(ids.containsAll(recovered)); + for (int cycle = 0; cycle < 2; cycle++) { + timer = timer(); when(plugin.getVoteTimer()).thenReturn(timer); + listener = new VotiferEvent(plugin, ready::get); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + assertEquals(512, overflow.size()); + listener.onVotiferEvent(receipt()); + assertEquals(0, listener.getPendingCaptureCount(), "full recovered ownership cannot admit another normal capture"); + assertFalse(overflow.enqueue("Steve", SITE, 123L)); + listener.stop(); workerTurn(overflow); overflow.close(); + assertEquals(ids, disk().stream().map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + assertTrue(processed.isEmpty()); timer.shutdownNow(); + } + } finally { release.countDown(); } + } + + @Test void fullQueueRetirementTransfersStartedOwnershipWithoutLosingTheReceipt() throws Exception { for (int index = 0; index < 256; index++) assertTrue(overflow.enqueue("Steve", SITE, 123L)); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); doAnswer(call -> { @@ -350,10 +480,10 @@ private void completedCurrentCallbackReconcilesExactTransferredOccurrence(String listener.onVotiferEvent(receipt()); try { assertTrue(entered.await(5, TimeUnit.SECONDS)); ready.set(false); listener.stop(); workerTurn(overflow); - assertEquals(1, listener.getPendingCaptureCount()); assertEquals(256, overflow.size()); + assertEquals(0, listener.getPendingCaptureCount()); assertEquals(257, overflow.size()); timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); - assertEquals(1, listener.getPendingCaptureCount()); assertTrue(processed.isEmpty()); - overflow.close(); assertEquals(256, disk().size()); + assertEquals(0, listener.getPendingCaptureCount()); assertTrue(processed.isEmpty()); + overflow.close(); assertEquals(257, disk().size()); } finally { release.countDown(); } } @Test void completionAfterSealedSnapshotReportsUncertaintyWithoutRetiredOverwrite() throws Exception { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java index 1daf81490f..b30492d339 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierVoteOverflowQueueTest.java @@ -339,7 +339,7 @@ void overCapacityRecoveredStoreFailsClosedWithoutTruncation(@TempDir Path dataFo when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); Path file = dataFolder.resolve("VotifierVoteQueue.yml"); - String original = "Votes:\n" + ("- {Username: Steve, ServiceSite: example.org, Time: 123}\n").repeat(257); + String original = "Votes:\n" + ("- {Username: Steve, ServiceSite: example.org, Time: 123}\n").repeat(513); Files.writeString(file, original); var transferred = new java.util.concurrent.CompletableFuture(); try (var queue = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> { }, () -> false)) { diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 1ac057efae..197caff324 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -107,13 +107,13 @@ Canonical date accounting requires an explicit transport vote ID; a legacy total Receiving-proxy retries and offline/time-change caches retain occurrence metadata separately from their normal time, including restart recovery. Existing JSON and SQL opaque totals payloads carry a bounded tagged trailing token; established totals and legacy UUID parsers ignore it. Totals updates preserve it. Legacy cache rows without the token retain normal voting effects but cannot prove date-event occurrence. Malformed, oversized, or conflicting metadata also excludes only date membership and never replaces ordinary processing time. Sender discovery and acknowledgement outboxes preserve the token across retries and restart. No SQL schema or dependency upgrade is required. -Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries). A bounded 256-entry ingress registry owns validated receipts before executor submission and releases each only when normal processing completes or overflow confirms transfer. STARTED callbacks remain owned while blocked; retirement transfers them as well as callbacks that have not started before vote-executor cancellation, preserving the receipt UUID/time. Each transferred STARTED entry remains fenced from overflow execution until the original callback finishes. Successful, non-interrupted completion acknowledges only that exact transferred occurrence, including completion before or after transfer admission; exceptional/interrupted processing retains the recovery record. Failed transfers retain bounded capture ownership and emit an explicit admission failure. The overflow is published as a memory owner before listener registration; its independent worker loads recovered rows before accepting transfers, so cancelling the vote executor cannot cancel queue setup or overwrite recovered receipts. Queue-file loading and ordinary persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. A full recovered queue, an over-capacity persisted file or an unreadable queue explicitly rejects new transfers without evicting recovered votes or replacing the rejected file. Initialization and queued transfers remain bounded; they are not additional reward or accounting engines. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. +Local Votifier capture is registered at the start of post-load, before date definitions and normal reward handlers initialize. Captured events use the existing bounded vote executor (256 admissions) and durable Votifier overflow (256 entries). A bounded 256-entry ingress registry holds validated receipts; known queue capacity is reserved before executor submission, while unknown initial capacity remains provisional. The registry releases an admitted capture only when normal processing completes or the queue confirms ownership transfer. STARTED callbacks remain owned while blocked; retirement transfers them as well as callbacks that have not started before vote-executor cancellation, preserving the receipt UUID/time. Each transferred STARTED entry remains fenced from overflow execution until the original callback finishes. Successful, non-interrupted completion acknowledges only that exact transferred occurrence, including completion before or after transfer admission; exceptional/interrupted processing retains the recovery record. Failed transfers retain bounded capture ownership and emit an explicit admission failure. The overflow is published as a memory owner before listener registration; its independent worker loads recovered rows before accepting transfers, so cancelling the vote executor cannot cancel queue setup or overwrite recovered receipts. Queue-file loading and ordinary persistence stay on workers. The overflow opens only after date definitions and the accepted-vote consumer are ready, alongside the proxy-ingress readiness boundary. A full normal overflow lane rejects ordinary transfers, but reserved captures fit the bounded combined retirement snapshot. A full combined restored snapshot, an over-capacity persisted file or unreadable storage cannot admit another normal capture; recovered votes are not evicted or replaced with missing-state guesses. Initialization and queued transfers remain bounded; they are not additional reward or accounting engines. Repeated readiness calls do not register duplicate consumers, and retired callbacks retain queued entries for restart. The captured receipt timestamp is separate from normal reward/cooldown processing time; local time-change persistence carries it in an optional `CanonicalOccurrenceTime` key and retains the UUID under `LocalOccurrenceId`, while preserving the established `Time` field. Legacy overflow rows receive distinct UUIDs and are rewritten before admission; legacy timed-cache rows derive a stable migration UUID from their persisted row identity and save it before replay is scheduled. Explicit malformed IDs are withheld rather than replaced with fresh occurrences. Queue acknowledgement and ordinary vote rewards retain their existing semantics; stable date identity is not a transaction or exactly-once guarantee for external reward commands. `/vote dateevents` resolves stored identities and reads progress on the existing ordered user-data worker before returning messages to the player owner. `VoteSites` must be a YAML list containing strings; absent or empty lists mean all sites, while malformed filters disable only their event and retain queued reward handles. Recovered time-change replay is constructed and registered only after date definitions, reward handles and the accepted-vote consumer are ready. This also gates the constructor’s initial replay timer; starting local Votifier or proxy ingress cannot race a missing replay consumer. Extension events dispatched from a Bukkit/Paper/Folia gameplay thread are admitted to the existing ordered storage worker (at most 64 pending). The asynchronous event flag is not treated as a thread check. Deferred proxy accounting remains unconfirmed/quarantined until reconciliation; submission alone never acknowledges durable accounting. Reload, disable, or overload can withhold such extension occurrences. Native VotingPlugin asynchronous ingress retains synchronous completion reporting. -When the normal capture registry is full, validated receipts use the independent overflow admission lane directly; they do not need another normal executor/capture slot. If both bounded owners cannot admit a new receipt, rejection is explicit. A rejected transfer of an already captured receipt clears its in-flight state and retries with the same UUID/time through one coalesced 250 ms wakeup on the existing overflow worker. That wakeup targets only captures which already requested transfer, preserving normal callback execution and the STARTED release fence; listener stop can also retry failed transfers. No additional executor or unbounded waiter list is created, and retry wakeups stop when the overflow retires. +When the normal capture registry is full, validated receipts use the independent overflow admission lane directly; they do not need another normal executor/capture slot. If both bounded owners cannot admit a new receipt, rejection is explicit. A rejected transfer of an already captured receipt clears its in-flight state and retries with the same UUID/time through one coalesced 250 ms wakeup on the existing overflow worker. That wakeup targets only captures which already requested transfer, preserving normal callback execution and the STARTED release fence; listener stop submits one retirement batch that uses the captures' reserved snapshot capacity. No additional executor or unbounded waiter list is created, and retry wakeups stop when the overflow retires. Native queue retirement drains its accepted worker transfers and writes the final snapshot after loading, with a one-second lifecycle wait. A blocked worker or failed write is reported as incomplete publication, not a successful durable admission; sudden process death before asynchronous publication can still lose an unpublished receipt. This does not claim universal exactly-once normal rewards or uninterrupted receipt while bounded owners are full. @@ -123,7 +123,7 @@ Overflow replay removes a receipt only after the production listener reports suc Native event dispatch alone is not completion: Bukkit can skip disabled listeners or catch consumer exceptions while returning normally. Explicit accepted-consumer completion keeps transferred receipts until the pipeline actually returns; a deliberate cancellation before consumer execution remains a completed no-op. A callback resuming during graceful shutdown therefore cannot discard an unprocessed receipt. -Overflow writers hold an exclusive file lease until their final worker publication finishes. A replacement instance waits asynchronously on the existing bounded queue worker before loading that file; it cannot publish an empty or stale snapshot while another writer owns it. The small owner sidecar is retained across shutdown and is not queue data. A blocked predecessor can delay replacement admission, which remains bounded and reports unsuccessful transfers for retry. This coordinates participating plugin instances, including different class loaders; it cannot control older binaries or other software that writes without the lease. Failed final publication remains an explicit reconciliation case. +Overflow writers hold an exclusive file lease until their final worker publication finishes. A replacement instance waits asynchronously on the existing bounded queue worker before loading that file; it cannot publish an empty or stale snapshot while another writer owns it. The small owner sidecar is retained across shutdown and is not queue data. A blocked predecessor can delay replacement admission, which remains bounded and reports unsuccessful transfers for retry. This coordinates participating plugin instances, including different class loaders; it cannot control older binaries or other software that writes without the lease. Failed final publication retains that lease and the existing worker for retry; timeout still leaves recovery unconfirmed until publication succeeds. ## Testing the event menu @@ -144,3 +144,22 @@ For an authorized gameplay test on a test server: 5. Revoke permission or reload while a menu is open. Old navigation must not reopen a stale or unauthorized view. Check fresh navigation after restoring permission and reopening the command. Repeat player scheduling checks on the supported server platforms. A passing local suite is not evidence of a live Paper/Folia/proxy gameplay run; record those results separately when performed. + +### Votifier capture retirement and capacity + +Native intake keeps the ordinary overflow lane at 256 entries and reserves separate final-snapshot capacity for up to +256 normal captured receipts. The queue is the capacity owner: restored rows and current capture reservations together +are capped at 512. A full restored combined snapshot rejects new normal capture admission until processing frees room; +repeated shutdown/restart cannot grow the retained file indefinitely. Unknown initial disk capacity is provisional: +initialization and bounded deferred admission run on the existing queue worker before normal executor submission. + +Shutdown stops intake, submits one coalesced capture-retirement batch, and then publishes the final queue snapshot on that +same worker. The existing YAML row format, receipt timestamp and occurrence ID are preserved, including captures whose +normal executor callback was cancelled. A started original callback retains execution ownership until its completion +status is known. Confirmed completion before the final seal removes its queue occurrence; completion after the seal +reports uncertainty and cannot overwrite a successor's file. + +The close caller waits at most one second per call. Failed final publication retains the worker and file owner for retry, +and a replacement waits to acquire ownership; a timeout means recovery is unconfirmed. This does not guarantee completion +if the process exits while storage remains unavailable. Unreadable or unowned initial storage is never replaced with a +missing-state guess. Normal overflow admission remains distinct from a confirmed durable checkpoint. From 071ba3edec2f1fdf0da343996c7c8e2fdf999a2d Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 14 Oct 2026 06:54:17 -0600 Subject: [PATCH 21/22] Retain capture retirement while queue ownership is pending --- .../listeners/VotifierVoteOverflowQueue.java | 163 ++++++++++++------ .../VotifierStartedCaptureOwnershipTest.java | 109 ++++++++++++ docs/date-vote-milestones.md | 9 + 3 files changed, 230 insertions(+), 51 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index a5c0f688ff..8be12caef5 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -97,6 +97,11 @@ public interface ConfirmedOccurrenceProcessor { private volatile boolean loadFailed; private int pendingIngress; private int pendingCaptureAdmissions; + private int pendingRetirementCaptures; + private boolean retirementRequested; + private boolean initializationRetryScheduled; + private boolean initializationFailureReported; + private boolean queueFileObserved; private final Set captureReservations = new java.util.HashSet<>(); private final boolean asynchronousInitialization; private long stateVersion; @@ -315,49 +320,81 @@ public record RetiringCapture(String username, String serviceSite, long time, UU public void retireCaptures(List captures) { var snapshot = List.copyOf(captures); if (snapshot.size() > MAX_CAPTURED) throw new IllegalArgumentException("capture retirement exceeds capacity"); - Runnable transfer = () -> { - boolean[] results = new boolean[snapshot.size()]; - for (int index = 0; index < snapshot.size(); index++) { - var capture = snapshot.get(index); - boolean accepted = false; + if (snapshot.isEmpty()) return; + class Transfer implements Runnable { + @Override public void run() { synchronized (lock) { - if (!closed && loaded && !loadFailed) { - var existing = occurrence(capture.id()); - if (capture.completed().getAsBoolean()) { - captureReservations.remove(capture.id()); - accepted = true; - } else if (existing != null) { - accepted = matches(existing, capture.username(), capture.serviceSite(), capture.time()); - if (accepted) captureReservations.remove(capture.id()); - } else if (entries.size() < MAX_RETAINED_ENTRIES - && (captureReservations.contains(capture.id()) - || entries.size() + captureReservations.size() < MAX_RETAINED_ENTRIES)) { - entries.addLast(new PendingVote(capture.username(), capture.serviceSite(), capture.time(), - capture.id(), capture.executionReleased())); - captureReservations.remove(capture.id()); - stateVersion++; requestPersistenceLocked(); - accepted = true; - } + if (!closed && (!loaded || loadFailed)) { + scheduleInitializationRetryLocked(); + worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; } } - results[index] = accepted; - } - for (int index = 0; index < snapshot.size(); index++) { - try { snapshot.get(index).completion().accept(results[index]); } - catch (RuntimeException | Error failure) { - try { plugin.getLogger().warning("Votifier retirement callback failed; queue ownership is retained"); } - catch (RuntimeException | Error ignored) { } + boolean[] results = new boolean[snapshot.size()]; + try { + for (int index = 0; index < snapshot.size(); index++) { + var capture = snapshot.get(index); + boolean completed = false; + try { completed = capture.completed().getAsBoolean(); } + catch (RuntimeException | Error unknown) { /* Retain unconfirmed completion. */ } + synchronized (lock) { + if (!closed && loaded && !loadFailed) { + var existing = occurrence(capture.id()); + if (completed) { + captureReservations.remove(capture.id()); + results[index] = true; + } else if (existing != null) { + results[index] = matches(existing, capture.username(), capture.serviceSite(), capture.time()); + if (results[index]) captureReservations.remove(capture.id()); + } else if (entries.size() < MAX_RETAINED_ENTRIES + && (captureReservations.contains(capture.id()) + || entries.size() + captureReservations.size() < MAX_RETAINED_ENTRIES)) { + entries.addLast(new PendingVote(capture.username(), capture.serviceSite(), capture.time(), + capture.id(), capture.executionReleased())); + captureReservations.remove(capture.id()); + stateVersion++; requestPersistenceLocked(); + results[index] = true; + } + } + } + } + // Retain the whole batch before any external completion callback. + for (int index = 0; index < snapshot.size(); index++) { + try { snapshot.get(index).completion().accept(results[index]); } + catch (RuntimeException | Error failure) { + try { plugin.getLogger().warning("Votifier retirement callback failed; queue ownership is retained"); } + catch (RuntimeException | Error ignored) { } + } + } + } finally { + synchronized (lock) { pendingRetirementCaptures -= snapshot.size(); } } } - }; + } boolean submitted = false; synchronized (lock) { - if (!closed && !closing) { - try { worker.execute(transfer); submitted = true; } - catch (RejectedExecutionException rejected) { } + if (!closed && !closing && pendingRetirementCaptures + snapshot.size() <= MAX_CAPTURED) { + pendingRetirementCaptures += snapshot.size(); + try { worker.execute(new Transfer()); retirementRequested = true; submitted = true; } + catch (RejectedExecutionException rejected) { pendingRetirementCaptures -= snapshot.size(); } } } - if (!submitted) for (var capture : snapshot) capture.completion().accept(false); + if (!submitted) for (var capture : snapshot) { + try { capture.completion().accept(false); } + catch (RuntimeException | Error ignored) { /* Reject every bounded caller, including the suffix. */ } + } + } + + private void scheduleInitializationRetryLocked() { + if (closed || initializationRetryScheduled || loaded && !loadFailed) return; + initializationRetryScheduled = true; + worker.schedule(() -> { + synchronized (lock) { + initializationRetryScheduled = false; + if (closed) return; + } + load(); + }, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); } private PendingVote occurrence(UUID id) { @@ -599,16 +636,24 @@ private void persistLoop() { private void load() { ArrayDeque recovered = new ArrayDeque<>(); + boolean successful = false; try { - synchronized (lock) { if (closed) return; } + synchronized (lock) { if (closed || loaded && !loadFailed) return; } if (!acquireOwner()) { - if (asynchronousInitialization && !closing) { - worker.schedule(this::load, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); - return; - } + synchronized (lock) { + if (asynchronousInitialization && !closing || pendingRetirementCaptures > 0) { + scheduleInitializationRetryLocked(); + return; + } + } throw new IOException("queue file is owned by another active instance"); } - if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) return; + if (!Files.exists(file, LinkOption.NOFOLLOW_LINKS)) { + if (queueFileObserved) throw new IOException("queue file disappeared before recovery"); + successful = true; + return; + } + queueFileObserved = true; YamlConfiguration yaml = new YamlConfiguration(); yaml.loadFromString(readQueueFile()); Object raw = yaml.get("Votes"); @@ -647,20 +692,28 @@ private void load() { } recovered.addLast(new PendingVote(name, site, timestamp.longValue(), localOccurrenceId)); } - synchronized (lock) { entries.addAll(recovered); } + synchronized (lock) { entries.clear(); entries.addAll(recovered); } if (skipped) { synchronized (lock) { stateVersion++; requestPersistenceLocked(); } } + successful = true; } catch (Exception failure) { loadFailed = true; - plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName()); + if (!initializationFailureReported) { + initializationFailureReported = true; + plugin.getLogger().warning("Unable to load queued Votifier votes: " + failure.getClass().getSimpleName()); + } } finally { if (ownerLock == null && !loadFailed) return; + if (successful) loadFailed = false; loaded = true; - synchronized (lock) { scheduleDrainLocked(); } + synchronized (lock) { + scheduleDrainLocked(); + if (loadFailed && pendingRetirementCaptures > 0) scheduleInitializationRetryLocked(); + } } } @@ -785,16 +838,19 @@ private void writeSnapshotLocked(List snapshot) throws IOException @Override public void close() { - if (asynchronousInitialization) { closeInitializedWorker(); return; } - List snapshot; + List snapshot = null; + boolean initializedWorker; synchronized (lock) { - if (closed) return; - closed = true; - // At-least-once delivery: retain any unacknowledged callback. A callback - // interrupted during shutdown must be replayed rather than silently lost. - snapshot = new ArrayList<>(entries); - persistenceScheduled = false; + initializedWorker = asynchronousInitialization || retirementRequested; + if (!initializedWorker) { + if (closed) return; + closed = true; + // Legacy callers keep their existing close path until retirement admission. + snapshot = new ArrayList<>(entries); + persistenceScheduled = false; + } } + if (initializedWorker) { closeInitializedWorker(); return; } worker.shutdownNow(); try { worker.awaitTermination(1, TimeUnit.SECONDS); @@ -835,6 +891,11 @@ private void finishClosePublication() { long version; synchronized (lock) { if (closed) return; + if (pendingRetirementCaptures > 0) { + if (!loaded || loadFailed) scheduleInitializationRetryLocked(); + worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } snapshot = new ArrayList<>(entries); version = stateVersion; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java index 49d8f38efd..f69ad60946 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java @@ -414,6 +414,115 @@ private void completedCurrentCallbackReconcilesExactTransferredOccurrence(String } } + @Test void closingReplacementKeepsProvisionalCaptureUntilPredecessorReleasesOwnership() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + VotifierVoteOverflowQueue predecessor = overflow; + UUID recovered = UUID.randomUUID(); + assertTrue(predecessor.enqueue("Steve", SITE, 123L, recovered)); + workerTurn(predecessor); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); + workerTurn(overflow); + assertFalse(overflow.isInitializationComplete()); + listener.onVotiferEvent(receipt()); + var original = retainedReceipt(); + try { + ready.set(false); listener.stop(); + long began = System.nanoTime(); overflow.close(); + assertTrue(System.nanoTime() - began < TimeUnit.SECONDS.toNanos(2)); + assertFalse(worker(overflow).isShutdown(), "closing worker must retain provisional retirement ownership"); + predecessor.close(); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + var rows = disk(); assertEquals(2, rows.size()); + assertEquals(Set.of(recovered, original.id()), rows.stream() + .map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + var retained = rows.stream().filter(row -> original.id().toString().equals(row.get("LocalOccurrenceId"))).findFirst().orElseThrow(); + assertEquals(original.time(), ((Number) retained.get("Time")).longValue()); + } finally { + predecessor.close(); overflow.close(); + } + } + + @Test void closingReplacementRetainsCapturesAcrossUnknownStorageUntilValidRepair() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + VotifierVoteOverflowQueue predecessor = overflow; + UUID recovered = UUID.randomUUID(); + assertTrue(predecessor.enqueue("Steve", SITE, 123L, recovered)); workerTurn(predecessor); + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + listener.onVotiferEvent(receipt()); var original = retainedReceipt(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + Path file = directory.resolve("VotifierVoteQueue.yml"); byte[] valid = null; + try { + ready.set(false); listener.stop(); overflow.close(); + worker(overflow).execute(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + predecessor.close(); valid = Files.readAllBytes(file); + String broken = "Votes: [unterminated"; Files.writeString(file, broken); release.countDown(); + var failed = VotifierVoteOverflowQueue.class.getDeclaredField("loadFailed"); failed.setAccessible(true); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!failed.getBoolean(overflow) && System.nanoTime() < deadline) Thread.sleep(10); + assertTrue(failed.getBoolean(overflow)); assertFalse(worker(overflow).isShutdown()); + assertEquals(broken, Files.readString(file)); + Files.delete(file); Thread.sleep(600); + assertFalse(Files.exists(file), "observed unknown history cannot become an empty install"); + assertFalse(worker(overflow).isShutdown()); + Files.write(file, valid); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(Set.of(recovered, original.id()), disk().stream() + .map(row -> UUID.fromString((String) row.get("LocalOccurrenceId"))) + .collect(java.util.stream.Collectors.toSet())); + } finally { + release.countDown(); predecessor.close(); + if (!Files.exists(file) && valid != null) Files.write(file, valid); + overflow.close(); + } + } + + @Test void legacyConstructorRetirementRunsBeforeItsFinalSnapshot() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); overflow.close(); + overflow = new VotifierVoteOverflowQueue(plugin, (site, user) -> {}); + UUID id = UUID.randomUUID(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + worker(overflow).execute(() -> { + entered.countDown(); + try { release.await(10, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var accepted = new java.util.concurrent.atomic.AtomicBoolean(); + overflow.retireCaptures(List.of(new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 123L, id, + () -> true, () -> false, accepted::set))); + try { + overflow.close(); assertFalse(worker(overflow).isShutdown()); release.countDown(); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(accepted.get()); assertEquals(id.toString(), disk().getFirst().get("LocalOccurrenceId")); + } finally { release.countDown(); overflow.close(); } + } + + @Test void waitingRetirementAdmissionIsGloballyBounded() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + VotifierVoteOverflowQueue predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, id) -> {}, () -> false); + workerTurn(overflow); + var batch = new ArrayList(); + for (int i = 0; i < 256; i++) batch.add(new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 123L, + UUID.randomUUID(), () -> true, () -> false, accepted -> {})); + var rejected = new java.util.concurrent.atomic.AtomicReference(); + try { + overflow.retireCaptures(batch); + overflow.retireCaptures(List.of(new VotifierVoteOverflowQueue.RetiringCapture("Steve", SITE, 456L, + UUID.randomUUID(), () -> true, () -> false, rejected::set))); + assertEquals(Boolean.FALSE, rejected.get()); overflow.close(); predecessor.close(); + assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); assertEquals(256, disk().size()); + } finally { predecessor.close(); overflow.close(); } + } + @Test void throwingRetirementCallbackDoesNotPreventTheRestOfTheBatchBeingRetained() throws Exception { when(plugin.getLogger()).thenReturn(mock(Logger.class)); UUID first = UUID.randomUUID(), second = UUID.randomUUID(); diff --git a/docs/date-vote-milestones.md b/docs/date-vote-milestones.md index 197caff324..9963889d5e 100644 --- a/docs/date-vote-milestones.md +++ b/docs/date-vote-milestones.md @@ -163,3 +163,12 @@ The close caller waits at most one second per call. Failed final publication ret and a replacement waits to acquire ownership; a timeout means recovery is unconfirmed. This does not guarantee completion if the process exits while storage remains unavailable. Unreadable or unowned initial storage is never replaced with a missing-state guess. Normal overflow admission remains distinct from a confirmed durable checkpoint. + +A replacement that retires captures before acquiring its predecessor's queue owner keeps one bounded retirement batch +on the existing worker. It retries ownership and authoritative loading before merging and publishing those receipts; +close does not discard an uninitialized batch. Unknown or corrupt storage is retained for repair. If an already observed +queue file disappears, its absence cannot authorize an empty replacement. Retirement admissions across batches remain +capped at 256; normal and recovered row limits remain unchanged. Provisional receipt admission is not confirmed ownership +until recovery and capacity checks complete. A queue with no retirement receipts can close without publishing unknown +initial state. These retries do not extend the caller's shutdown wait or promise recovery if the process exits before +ownership/storage becomes available. From 18f2fe92b226cdb9b25aeed112925327a2dd35a3 Mon Sep 17 00:00:00 2001 From: Ben Date: Thu, 15 Oct 2026 18:36:07 -0600 Subject: [PATCH 22/22] Retain saturated Votifier receipts until overflow initialization --- .../listeners/VotifierVoteOverflowQueue.java | 44 +++++-- .../VotifierStartedCaptureOwnershipTest.java | 116 +++++++++++++++++- 2 files changed, 149 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java index 8be12caef5..ecb8e70310 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotifierVoteOverflowQueue.java @@ -208,17 +208,40 @@ public void enqueueAfterInitialization(String username, String serviceSite, long /** A STARTED original callback retains execution ownership until its outcome is known. */ public void enqueueAfterInitialization(String username, String serviceSite, long occurredAt, UUID localOccurrenceId, java.util.function.BooleanSupplier executionReleased, Consumer completion) { + if (username == null || serviceSite == null || occurredAt <= 0 || localOccurrenceId == null) { + completion.accept(false); + return; + } + var finished = new java.util.concurrent.atomic.AtomicBoolean(); + class Transfer implements Runnable { + void finish(boolean accepted) { + if (!finished.compareAndSet(false, true)) return; + synchronized (lock) { pendingIngress--; } + completion.accept(accepted); + } + @Override public void run() { + try { + synchronized (lock) { + if (!closed && !loaded) { + // Admission owns this receipt, including the saturated + // path which has no entry in the listener's capture map. + scheduleInitializationRetryLocked(); + worker.schedule(this, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); + return; + } + } + } catch (RejectedExecutionException rejected) { finish(false); return; } + boolean accepted = false; + try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true, executionReleased); } + finally { finish(accepted); } + } + } boolean submitted = false; synchronized (lock) { if (!closed && !closing && pendingIngress < MAX_ENTRIES) { pendingIngress++; try { - worker.execute(() -> { - boolean accepted; - try { accepted = enqueueStored(username, serviceSite, occurredAt, localOccurrenceId, true, executionReleased); } - finally { synchronized (lock) { pendingIngress--; } } - completion.accept(accepted); - }); + worker.execute(new Transfer()); submitted = true; } catch (RejectedExecutionException rejected) { pendingIngress--; } } @@ -242,7 +265,8 @@ private boolean enqueueStored(String username, String serviceSite, long occurred if (same) captureReservations.remove(localOccurrenceId); return same; } - if (entries.size() >= MAX_ENTRIES || !captureReservations.contains(localOccurrenceId) + int entryLimit = closing && admittedTransfer ? MAX_RETAINED_ENTRIES : MAX_ENTRIES; + if (entries.size() >= entryLimit || !captureReservations.contains(localOccurrenceId) && entries.size() + captureReservations.size() >= MAX_RETAINED_ENTRIES) return false; entries.addLast(new PendingVote(username, serviceSite, occurredAt, localOccurrenceId, executionReleased)); captureReservations.remove(localOccurrenceId); @@ -641,7 +665,7 @@ private void load() { synchronized (lock) { if (closed || loaded && !loadFailed) return; } if (!acquireOwner()) { synchronized (lock) { - if (asynchronousInitialization && !closing || pendingRetirementCaptures > 0) { + if (asynchronousInitialization && !closing || pendingRetirementCaptures > 0 || pendingIngress > 0) { scheduleInitializationRetryLocked(); return; } @@ -841,7 +865,7 @@ public void close() { List snapshot = null; boolean initializedWorker; synchronized (lock) { - initializedWorker = asynchronousInitialization || retirementRequested; + initializedWorker = asynchronousInitialization || retirementRequested || pendingIngress > 0; if (!initializedWorker) { if (closed) return; closed = true; @@ -891,7 +915,7 @@ private void finishClosePublication() { long version; synchronized (lock) { if (closed) return; - if (pendingRetirementCaptures > 0) { + if (pendingRetirementCaptures > 0 || pendingIngress > 0) { if (!loaded || loadFailed) scheduleInitializationRetryLocked(); worker.schedule(this::finishClosePublication, RETRY_DELAY_MILLIS, TimeUnit.MILLISECONDS); return; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java index f69ad60946..16250f8f5f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotifierStartedCaptureOwnershipTest.java @@ -58,8 +58,11 @@ private static ScheduledExecutorService timer() { } @AfterEach void close() { timer.shutdownNow(); overflow.close(); } private com.vexsoftware.votifier.model.VotifierEvent receipt() { + return receipt("Steve"); + } + private com.vexsoftware.votifier.model.VotifierEvent receipt(String username) { var vote = mock(Vote.class); when(vote.getServiceName()).thenReturn(SITE); - when(vote.getUsername()).thenReturn("Steve"); when(vote.getAddress()).thenReturn("127.0.0.1"); + when(vote.getUsername()).thenReturn(username); when(vote.getAddress()).thenReturn("127.0.0.1"); var event = mock(com.vexsoftware.votifier.model.VotifierEvent.class); when(event.getVote()).thenReturn(vote); return event; } private static ScheduledThreadPoolExecutor worker(VotifierVoteOverflowQueue queue) throws Exception { @@ -68,6 +71,10 @@ private static ScheduledThreadPoolExecutor worker(VotifierVoteOverflowQueue queu } private static void workerTurn(VotifierVoteOverflowQueue queue) throws Exception { worker(queue).submit(() -> {}).get(5, TimeUnit.SECONDS); } private void processingTurn() throws Exception { timer.submit(() -> {}).get(5, TimeUnit.SECONDS); } + private int pendingIngress() throws Exception { + var field = VotifierVoteOverflowQueue.class.getDeclaredField("pendingIngress"); field.setAccessible(true); + return field.getInt(overflow); + } private List> disk() { return YamlConfiguration.loadConfiguration(directory.resolve("VotifierVoteQueue.yml").toFile()).getMapList("Votes"); } private record CapturedReceipt(UUID id, long time) { } private CapturedReceipt retainedReceipt() throws Exception { @@ -89,6 +96,113 @@ private List recoverFullQueue() throws Exception { assertEquals(256, overflow.size()); return ids; } + @Test void saturatedIngressWaitsForInitializationAndPreservesEveryReceipt() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + var delivered = new CountDownLatch(257); + doAnswer(call -> { + var event = call.getArgument(0, PlayerVoteEvent.class); + event.setNativeProcessingCompleted(true); processed.add(event); delivered.countDown(); return null; + }).when(events).callEvent(any(PlayerVoteEvent.class)); + try { + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + long before = System.currentTimeMillis(); listener.onVotiferEvent(receipt("Extra")); + long after = System.currentTimeMillis(); workerTurn(overflow); + assertFalse(overflow.isInitializationComplete()); + assertEquals(256, listener.getPendingCaptureCount()); + assertEquals(1, pendingIngress(), "saturated receipt must remain owned until initialization resolves"); + overflow.start(); predecessor.close(); + assertTrue(delivered.await(5, TimeUnit.SECONDS)); + processingTurn(); workerTurn(overflow); + assertEquals(257, processed.stream().map(PlayerVoteEvent::getLocalOccurrenceId).distinct().count()); + var extra = processed.stream().filter(event -> "Extra".equals(event.getPlayer())).findFirst().orElseThrow(); + assertTrue(extra.getCanonicalOccurrenceTime() >= before && extra.getCanonicalOccurrenceTime() <= after); + assertEquals(0, pendingIngress()); assertEquals(0, listener.getPendingCaptureCount()); + } finally { ready.set(false); listener.stop(); predecessor.close(); overflow.close(); } + } + + @Test void closingSaturatedReplacementPublishesBoundedPendingIngressWithCaptures() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, ready::get); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + try { + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt()); + long before = System.currentTimeMillis(); + for (int index = 0; index < 256; index++) listener.onVotiferEvent(receipt("Extra" + index)); + long after = System.currentTimeMillis(); + listener.onVotiferEvent(receipt("BeyondCapacity")); workerTurn(overflow); + assertEquals(256, listener.getPendingCaptureCount()); assertEquals(256, pendingIngress()); + ready.set(false); listener.stop(); overflow.close(); + assertFalse(worker(overflow).isShutdown(), "final publication must retain both bounded ownership lanes"); + predecessor.close(); assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + var rows = disk(); assertEquals(512, rows.size()); + assertEquals(512, rows.stream().map(row -> row.get("LocalOccurrenceId")).distinct().count()); + assertFalse(rows.stream().anyMatch(row -> "BeyondCapacity".equals(row.get("Username")))); + var extra = rows.stream().filter(row -> "Extra255".equals(row.get("Username"))).findFirst().orElseThrow(); + long occurredAt = ((Number) extra.get("Time")).longValue(); + assertTrue(occurredAt >= before && occurredAt <= after); + UUID id = UUID.fromString((String) extra.get("LocalOccurrenceId")); + try (var restarted = VotifierVoteOverflowQueue.initializeAsync(plugin, (site, user, time, occurrence) -> {}, () -> false)) { + workerTurn(restarted); assertEquals(512, restarted.size()); + assertTrue(disk().stream().anyMatch(row -> id.toString().equals(row.get("LocalOccurrenceId")) + && ((Number) row.get("Time")).longValue() == occurredAt)); + } + } finally { ready.set(false); listener.stop(); predecessor.close(); overflow.close(); } + } + + @Test void closingIngressWithoutCaptureRetirementWaitsForFileOwner() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> false); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + UUID id = UUID.randomUUID(); var completions = new AtomicInteger(); var admitted = new AtomicBoolean(); + try { + overflow.enqueueAfterInitialization("Extra", SITE, 123L, id, accepted -> { + admitted.set(accepted); completions.incrementAndGet(); + }); + workerTurn(overflow); assertEquals(1, pendingIngress()); assertEquals(0, completions.get()); + overflow.close(); assertFalse(worker(overflow).isShutdown()); + predecessor.close(); assertTrue(worker(overflow).awaitTermination(5, TimeUnit.SECONDS)); + assertEquals(1, completions.get()); assertTrue(admitted.get()); + var rows = disk(); assertEquals(1, rows.size()); + assertEquals(id.toString(), rows.get(0).get("LocalOccurrenceId")); + assertEquals(123L, ((Number) rows.get(0).get("Time")).longValue()); + } finally { predecessor.close(); overflow.close(); } + } + + @Test void malformedIngressCannotOccupyProvisionalCapacity() throws Exception { + var predecessor = overflow; + overflow = VotifierVoteOverflowQueue.initializeAsync(plugin, listener::processVote, () -> false); + when(plugin.getVotifierVoteOverflowQueue()).thenReturn(overflow); workerTurn(overflow); + var completions = new AtomicInteger(); var accepted = new AtomicBoolean(); + java.util.function.Consumer completion = result -> { + if (result) accepted.set(true); completions.incrementAndGet(); + }; + try { + overflow.enqueueAfterInitialization(null, SITE, 123L, UUID.randomUUID(), completion); + overflow.enqueueAfterInitialization("Extra", null, 123L, UUID.randomUUID(), completion); + overflow.enqueueAfterInitialization("Extra", SITE, 0L, UUID.randomUUID(), completion); + overflow.enqueueAfterInitialization("Extra", SITE, 123L, null, completion); + workerTurn(overflow); + assertEquals(4, completions.get()); assertFalse(accepted.get()); assertEquals(0, pendingIngress()); + } finally { predecessor.close(); overflow.close(); } + } + + @Test void initializedFullOverflowStillRejectsWithoutIndefiniteIngressRetry() throws Exception { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + ready.set(false); recoverFullQueue(); + var completions = new AtomicInteger(); var accepted = new AtomicBoolean(true); + overflow.enqueueAfterInitialization("Extra", SITE, 123L, UUID.randomUUID(), result -> { + accepted.set(result); completions.incrementAndGet(); + }); + workerTurn(overflow); + assertEquals(1, completions.get()); assertFalse(accepted.get()); + assertEquals(0, pendingIngress()); assertEquals(256, overflow.size()); + } + @Test void saturatedCapturesUseAllIndependentOverflowSlotsWithoutChangingReceiptIdentity() throws Exception { when(plugin.getLogger()).thenReturn(mock(Logger.class)); timer.shutdownNow(); timer = new BoundedScheduledExecutor(1, 256);