From 5c6608903dd98c5c8ff2293b8d745cdf2f0ac220 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:19:31 -0600 Subject: [PATCH 1/9] Persist timed vote queue snapshots atomically --- .../votingplugin/data/ServerData.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 a255b8d58..60776e1f3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Collection; import java.util.Locale; import java.util.Set; import java.util.UUID; @@ -110,6 +111,24 @@ public void clearTimedVoteCache() { saveData(); } + /** + * Replaces the timed vote cache and persists the complete snapshot with one save. + * + * @param votes pending timed votes in replay order + */ + public synchronized void replaceTimedVoteCache(Collection votes) { + ConfigurationSection data = getData(); + data.set("TimedVoteCache", null); + int index = 0; + for (VoteTimeQueue vote : votes) { + String path = "TimedVoteCache." + index++; + data.set(path + ".Name", vote.getName()); + data.set(path + ".Service", vote.getService()); + data.set(path + ".Time", vote.getTime()); + } + saveData(); + } + /** * Gets the auto cached placeholders. * From 620f089e6799fe73139c694b1481e3f6281d077a Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:19:33 -0600 Subject: [PATCH 2/9] Keep timed votes durable until processing completes --- .../timequeue/TimeQueueHandler.java | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) 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 3bf50342a..e285e6aad 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.ArrayList; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -48,9 +49,10 @@ public TimeQueueHandler(VotingPluginMain plugin) { * @param voteUsername the voter username * @param voteSiteName the vote site name */ - public void addVote(String voteUsername, String voteSiteName) { + public synchronized void addVote(String voteUsername, String voteSiteName) { timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + persistQueueSnapshot(); } /** @@ -76,12 +78,7 @@ public void postTimeChange(DateChangedEvent event) { } private void scheduleQueueProcessing(long delay, TimeUnit unit) { - boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> { - // Clear only after the bounded executor has admitted the task. If it is - // rejected, shutdown persistence can still recover the in-memory queue. - plugin.getServerData().clearTimedVoteCache(); - processQueue(); - }, delay, unit); + boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit); if (!admitted) { plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained."); scheduleRetry(); @@ -109,33 +106,38 @@ private void scheduleRetry() { /** * Processes all votes in the queue. */ - public void processQueue() { - while (getTimeChangeQueue().size() > 0) { - VoteTimeQueue vote = getTimeChangeQueue().remove(); + public synchronized void processQueue() { + while (true) { + VoteTimeQueue vote = getTimeChangeQueue().peek(); + if (vote == null) return; PlayerVoteEvent voteEvent = new PlayerVoteEvent( plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), vote.getService(), true); voteEvent.setTime(vote.getTime()); - plugin.getServer().getPluginManager().callEvent(voteEvent); - - if (voteEvent.isCancelled()) { - plugin.debug("Vote cancelled"); + try { + plugin.getServer().getPluginManager().callEvent(voteEvent); + } catch (RuntimeException failure) { + plugin.getLogger().warning("Unable to process queued time-change vote; retaining it for retry"); + plugin.debug(failure); + scheduleRetry(); return; } + + getTimeChangeQueue().remove(vote); + persistQueueSnapshot(); + if (voteEvent.isCancelled()) plugin.debug("Vote cancelled"); } } /** * Saves pending votes to server data. */ - public void save() { - if (!timeChangeQueue.isEmpty()) { - int num = 0; - for (VoteTimeQueue vote : timeChangeQueue) { - plugin.getServerData().addTimeVoted(num, vote); - num++; - } - } + public synchronized void save() { + persistQueueSnapshot(); timeChangeQueue.clear(); } + + private void persistQueueSnapshot() { + plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + } } From 4a1e2c91c086dc6f68aeed3b077ee0ee8a95c2e2 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:19:35 -0600 Subject: [PATCH 3/9] Test timed vote queue durability --- .../TimeQueueHandlerRejectionTest.java | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) 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 37ba7a49a..70f7125e0 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 @@ -10,6 +10,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; import static org.mockito.Mockito.reset; @@ -20,12 +21,15 @@ import java.util.logging.Logger; import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.Server; +import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.api.time.events.DateChangedEvent; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.timequeue.TimeQueueHandler; class TimeQueueHandlerRejectionTest { @@ -73,6 +77,57 @@ void dateChangeRejectionDoesNotEscapeBukkitEventHandler() { verify(logger, org.mockito.Mockito.atLeastOnce()).warning(anyString()); } + @Test + void newlyQueuedVoteIsPersistedImmediately() { + TimeQueueHandler handler = new TimeQueueHandler(plugin); + org.mockito.Mockito.clearInvocations(serverData); + + handler.addVote("Alex", "example.org"); + + org.mockito.ArgumentCaptor> snapshot = + org.mockito.ArgumentCaptor.forClass(java.util.Collection.class); + verify(serverData).replaceTimedVoteCache(snapshot.capture()); + assertEquals(2, snapshot.getValue().size()); + } + + @Test + void processingFailureRetainsDurableQueueHead() { + TimeQueueHandler handler = new TimeQueueHandler(plugin); + Server server = mock(Server.class); + PluginManager manager = mock(PluginManager.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + when(plugin.getVoteSiteManager().getVoteSiteName(true, "example.org")).thenReturn("example.org"); + doThrow(new IllegalStateException("listener failed")).when(manager).callEvent(any(PlayerVoteEvent.class)); + org.mockito.Mockito.clearInvocations(serverData); + + handler.processQueue(); + + assertEquals(1, handler.getTimeChangeQueue().size()); + verify(serverData, never()).replaceTimedVoteCache(any()); + } + + @Test + void cancelledVoteDoesNotStrandLaterQueuedVotes() { + TimeQueueHandler handler = new TimeQueueHandler(plugin); + handler.addVote("Alex", "second.example.org"); + Server server = mock(Server.class); + PluginManager manager = mock(PluginManager.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + when(plugin.getVoteSiteManager().getVoteSiteName(true, anyString())).thenAnswer(invocation -> invocation.getArgument(1)); + doAnswer(invocation -> { + ((PlayerVoteEvent) invocation.getArgument(0)).setCancelled(true); + return null; + }).when(manager).callEvent(any(PlayerVoteEvent.class)); + org.mockito.Mockito.clearInvocations(serverData); + + handler.processQueue(); + + assertEquals(0, handler.getTimeChangeQueue().size()); + verify(manager, times(2)).callEvent(any(PlayerVoteEvent.class)); + } + @Test void rejectedProcessingSchedulesOneBoundedRetry() { TimeQueueHandler handler = new TimeQueueHandler(plugin); From 97364b9c6b8735786936951e2a4c04e00af8c301 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:20:00 -0600 Subject: [PATCH 4/9] Fix timed queue regression test imports --- .../tests/timequeue/TimeQueueHandlerRejectionTest.java | 1 + 1 file changed, 1 insertion(+) 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 70f7125e0..670720c4c 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 @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; From 4252b7e2bf4a6a29d7c25d85746522bbb8a51713 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:23:54 -0600 Subject: [PATCH 5/9] Fence completed timed votes across persistence retry --- .../timequeue/TimeQueueHandler.java | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) 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 e285e6aad..6db20aa80 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -4,6 +4,9 @@ import java.time.ZoneId; import java.util.Queue; import java.util.ArrayList; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Set; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -32,6 +35,8 @@ public class TimeQueueHandler implements Listener { private VotingPluginMain plugin; private final AtomicBoolean retryPending = new AtomicBoolean(); private final AtomicInteger retryAttempts = new AtomicInteger(); + private final Set completedAwaitingPersistence = + Collections.newSetFromMap(new IdentityHashMap<>()); /** * Constructs a new TimeQueueHandler. @@ -110,22 +115,29 @@ public synchronized void processQueue() { while (true) { VoteTimeQueue vote = getTimeChangeQueue().peek(); if (vote == null) return; - PlayerVoteEvent voteEvent = new PlayerVoteEvent( - plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), - vote.getService(), true); - voteEvent.setTime(vote.getTime()); - try { - plugin.getServer().getPluginManager().callEvent(voteEvent); - } catch (RuntimeException failure) { - plugin.getLogger().warning("Unable to process queued time-change vote; retaining it for retry"); - plugin.debug(failure); + if (!completedAwaitingPersistence.contains(vote)) { + PlayerVoteEvent voteEvent = new PlayerVoteEvent( + plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), + vote.getService(), true); + voteEvent.setTime(vote.getTime()); + try { + plugin.getServer().getPluginManager().callEvent(voteEvent); + } catch (RuntimeException failure) { + plugin.getLogger().warning("Unable to process queued time-change vote; retaining it for retry"); + plugin.debug(failure); + scheduleRetry(); + return; + } + completedAwaitingPersistence.add(vote); + if (voteEvent.isCancelled()) plugin.debug("Vote cancelled"); + } + + if (!persistWithout(vote)) { scheduleRetry(); return; } - getTimeChangeQueue().remove(vote); - persistQueueSnapshot(); - if (voteEvent.isCancelled()) plugin.debug("Vote cancelled"); + completedAwaitingPersistence.remove(vote); } } @@ -140,4 +152,17 @@ public synchronized void save() { private void persistQueueSnapshot() { plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); } + + private boolean persistWithout(VoteTimeQueue completed) { + ArrayList remaining = new ArrayList<>(timeChangeQueue); + remaining.remove(completed); + try { + plugin.getServerData().replaceTimedVoteCache(remaining); + return true; + } catch (RuntimeException failure) { + plugin.getLogger().warning("Unable to persist completed time-change vote retirement; retrying storage only"); + plugin.debug(failure); + return false; + } + } } From c87c71cadb149a938fe2f4a160778eaf604dfe25 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:24:09 -0600 Subject: [PATCH 6/9] Test timed vote completion persistence retry --- .../TimeQueueHandlerRejectionTest.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) 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 670720c4c..23bb7ace4 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,6 +108,26 @@ void processingFailureRetainsDurableQueueHead() { verify(serverData, never()).replaceTimedVoteCache(any()); } + @Test + void completedVoteDoesNotRepeatEffectsWhenRetirementPersistenceRetries() { + TimeQueueHandler handler = new TimeQueueHandler(plugin); + Server server = mock(Server.class); + PluginManager manager = mock(PluginManager.class); + when(plugin.getServer()).thenReturn(server); + when(server.getPluginManager()).thenReturn(manager); + when(plugin.getVoteSiteManager().getVoteSiteName(true, "example.org")).thenReturn("example.org"); + doThrow(new IllegalStateException("save failed")).doNothing() + .when(serverData).replaceTimedVoteCache(any()); + + handler.processQueue(); + assertEquals(1, handler.getTimeChangeQueue().size()); + verify(manager, times(1)).callEvent(any(PlayerVoteEvent.class)); + + handler.processQueue(); + assertEquals(0, handler.getTimeChangeQueue().size()); + verify(manager, times(1)).callEvent(any(PlayerVoteEvent.class)); + } + @Test void cancelledVoteDoesNotStrandLaterQueuedVotes() { TimeQueueHandler handler = new TimeQueueHandler(plugin); From 53533cd6723f758a4ae152ab446349f9b9966e3a Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:26:32 -0600 Subject: [PATCH 7/9] Bound durable time-change vote admission --- .../votingplugin/timequeue/TimeQueueHandler.java | 13 +++++++++++++ 1 file changed, 13 insertions(+) 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 6db20aa80..edf8d9634 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -29,6 +29,7 @@ */ public class TimeQueueHandler implements Listener { private static final long TICKS_PER_SECOND = 20L; + static final int MAX_QUEUED_VOTES = 4096; @Getter private Queue timeChangeQueue = new ConcurrentLinkedQueue<>(); @@ -55,6 +56,10 @@ public TimeQueueHandler(VotingPluginMain plugin) { * @param voteSiteName the vote site name */ public synchronized void addVote(String voteUsername, String voteSiteName) { + if (timeChangeQueue.size() >= MAX_QUEUED_VOTES) { + plugin.getLogger().severe("Time-change vote queue is full; rejecting vote instead of expanding durable storage"); + return; + } timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName, LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); persistQueueSnapshot(); @@ -64,11 +69,19 @@ public synchronized void addVote(String voteUsername, String voteSiteName) { * Loads cached votes from server data and schedules queue processing. */ public void load() { + boolean truncated = false; for (String str : plugin.getServerData().getTimedVoteCacheKeys()) { + if (timeChangeQueue.size() >= MAX_QUEUED_VOTES) { + truncated = true; + break; + } ConfigurationSection data = plugin.getServerData().getTimedVoteCacheSection(str); timeChangeQueue .add(new VoteTimeQueue(data.getString("Name"), data.getString("Service"), data.getLong("Time"))); } + if (truncated) { + plugin.getLogger().severe("Timed vote recovery exceeded the bounded queue; excess persisted votes were not loaded"); + } scheduleQueueProcessing(120, TimeUnit.SECONDS); } From ed8122ca2870a33309579cdf5edcf19b9be1ea0a Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:26:46 -0600 Subject: [PATCH 8/9] Test bounded timed vote admission --- .../TimeQueueHandlerRejectionTest.java | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 23bb7ace4..b1d30c361 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 @@ -32,6 +32,7 @@ import com.bencodez.votingplugin.data.ServerData; import com.bencodez.votingplugin.events.PlayerVoteEvent; import com.bencodez.votingplugin.timequeue.TimeQueueHandler; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; class TimeQueueHandlerRejectionTest { private VotingPluginMain plugin; @@ -78,6 +79,22 @@ void dateChangeRejectionDoesNotEscapeBukkitEventHandler() { verify(logger, org.mockito.Mockito.atLeastOnce()).warning(anyString()); } + @Test + void fullTimeQueueRejectsInsteadOfExpandingDurableStorage() { + TimeQueueHandler handler = new TimeQueueHandler(plugin); + handler.getTimeChangeQueue().clear(); + for (int i = 0; i < 4096; i++) { + handler.getTimeChangeQueue().add(new VoteTimeQueue("Player" + i, "example.org", i + 1L)); + } + org.mockito.Mockito.clearInvocations(serverData, logger); + + handler.addVote("Overflow", "example.org"); + + assertEquals(4096, handler.getTimeChangeQueue().size()); + verify(serverData, never()).replaceTimedVoteCache(any()); + verify(logger).severe(org.mockito.ArgumentMatchers.contains("queue is full")); + } + @Test void newlyQueuedVoteIsPersistedImmediately() { TimeQueueHandler handler = new TimeQueueHandler(plugin); From 5ac5286297b3812db1daedd82eca0e33129c8916 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 28 Sep 2026 20:28:15 -0600 Subject: [PATCH 9/9] Finalize bounded timed vote durability follow-up --- .../bencodez/votingplugin/timequeue/TimeQueueHandler.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 edf8d9634..4926029d1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -160,10 +160,13 @@ public synchronized void processQueue() { public synchronized void save() { persistQueueSnapshot(); timeChangeQueue.clear(); + completedAwaitingPersistence.clear(); } private void persistQueueSnapshot() { - plugin.getServerData().replaceTimedVoteCache(new ArrayList<>(timeChangeQueue)); + ArrayList pending = new ArrayList<>(timeChangeQueue); + pending.removeAll(completedAwaitingPersistence); + plugin.getServerData().replaceTimedVoteCache(pending); } private boolean persistWithout(VoteTimeQueue completed) {