diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 5a74489bb..4204d571d 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -156,6 +156,10 @@ ${project.groupId}.votingplugin.bstats + + org.bouncycastle + ${project.groupId}.votingplugin.bouncycastle + xyz.upperlevel.spigot @@ -209,12 +213,17 @@ com.bencodez:simpleapi org/bouncycastle/** - com/bencodez/simpleapi/servercomm/http/** redis/clients/** org/eclipse/paho/** META-INF/versions/** + + org.bouncycastle:* + + META-INF/versions/25/** + + *:* @@ -433,6 +442,12 @@ 2.14.0 provided + + com.bencodez + simpleapi + 1.0.2-SNAPSHOT + compile + org.junit.jupiter junit-jupiter-engine diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 4f34d41c9..824bf0af0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -152,6 +152,11 @@ public class VotingPluginMain extends AdvancedCorePlugin { @Getter public static VotingPluginMain plugin; + /** Exact backend plugin file name Bukkit uses when consuming its update folder. */ + public File getLoadedPluginJarFile() { + return getFile(); + } + @Getter @Setter private ArrayList adminVoteCommand; @@ -167,6 +172,7 @@ public class VotingPluginMain extends AdvancedCorePlugin { private final ProcessedVoteCache backendProcessedVoteCache = new ProcessedVoteCache(); private final AtomicReference backendPluginMessageTarget = new AtomicReference<>(); private PluginMessageHandler backendPluginMessageRelay; + private com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage backendPluginMessageRelayOwner; private volatile BackendControlAutoEnrollment backendControlAutoEnrollment; private volatile BackendControlConnector backendControlConnector; private final ScheduledExecutorService backendControlConnectorLifecycle = Executors.newSingleThreadScheduledExecutor(runnable -> { @@ -1224,29 +1230,261 @@ public String getBackendHostedControlStatus() { } /** Recreates proxy transports after Control applies BungeeSettings.yml. */ - public synchronized void restartBackendProxyHandler() { + public void restartBackendProxyHandler() { + restartBackendProxyHandler(System.nanoTime() + TimeUnit.SECONDS.toNanos(25)); + } + + /** Recreates proxy transports while preserving the caller's end-to-end validation deadline. */ + public void restartBackendProxyHandler(long validationDeadlineNanos) { + BackendProxyRestart restart = prepareBackendProxyHandlerRestart(); + try { + validateBackendProxyHandlerRestart(restart, validationDeadlineNanos); + completeBackendProxyHandlerRestart(restart); + } catch (RuntimeException failure) { + abortBackendProxyHandlerRestart(restart); + throw failure; + } + } + + /** Constructed on the Bukkit thread, prepared and validated off-thread, then atomically published on Bukkit. */ + public static final class BackendProxyRestart { + private final BackendProxyHandler previous; + private final BackendProxyHandler replacement; + private BungeeMethod replacementMethod; + private final boolean disabled; + private final boolean previousRequiresPreparation; + private volatile boolean previousPrepared; + private boolean presenceStoppedForDisablePreparation; + // Redis listener retirement can wait for callbacks and listener shutdown. It is + // completed by the Control worker during validation, before the final Bukkit + // publication callback, and must be restored if that publication is abandoned. + private volatile boolean redisHandoffCompleted; + private boolean redisHandoffInProgress; + private boolean finished; + private boolean abandonmentRequested; + // Same-method socket and MQTT replacements must not start a second runtime + // endpoint before worker-side preparation retires the predecessor. + private boolean replacementLoadDeferred; + private volatile boolean published; + + private BackendProxyRestart(BackendProxyHandler previous, BackendProxyHandler replacement, boolean disabled, + boolean previousRequiresPreparation) { + this.previous = previous; + this.replacement = replacement; + this.disabled = disabled; + this.previousRequiresPreparation = previousRequiresPreparation; + } + + /** Only exclusive same-method transports may be restored by the Control worker. */ + public boolean requiresWorkerRollback() { + return previousPrepared && previous != null && replacement != null + && ((previous.getMethod() == BungeeMethod.SOCKETS && effectiveReplacementMethod() == BungeeMethod.SOCKETS) + || (previous.getMethod() == BungeeMethod.MQTT + && effectiveReplacementMethod() == BungeeMethod.MQTT)); + } + + private BungeeMethod effectiveReplacementMethod() { + BungeeMethod loadedMethod = replacement.getMethod(); + return loadedMethod != null ? loadedMethod : replacementMethod; + } + } + + public static final class BackendProxyRestartPreparationException extends RuntimeException { + private static final long serialVersionUID = 1L; + private final BackendProxyRestart restart; + + private BackendProxyRestartPreparationException(BackendProxyRestart restart, RuntimeException cause) { + super(cause); + this.restart = restart; + } + + public BackendProxyRestart restart() { return restart; } + } + + public synchronized BackendProxyRestart prepareBackendProxyHandlerRestart() { BackendProxyHandler previous = backendProxyHandler; if (!bungeeSettings.isUseBungeecoord()) { - backendProxyHandler = null; - if (previous != null) previous.close(); - BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; - backendControlAutoEnrollment = null; - if (enrollment != null) enrollment.close(); - return; + boolean previousRequiresPreparation = previous != null + && (previous.requiresPreparationForReplacement() || previous.requiresRedisRetirement()); + return new BackendProxyRestart(previous, null, true, previousRequiresPreparation); } BungeeMethod replacementMethod = BungeeMethod.getByName(bungeeSettings.getBungeeMethod()); - if (previous != null) previous.prepareForReplacement(replacementMethod); + boolean sameSocketReplacement = previous != null && previous.getMethod() == BungeeMethod.SOCKETS + && replacementMethod == BungeeMethod.SOCKETS; + boolean sameMqttReplacement = previous != null && previous.getMethod() == BungeeMethod.MQTT + && replacementMethod == BungeeMethod.MQTT; + boolean deferredReplacementLoad = sameSocketReplacement || sameMqttReplacement; + // Every transition away from an active HTTP transport must first drain its + // durable outgoing queue. Restricting preparation to HTTP-to-HTTP swaps can + // strand accepted deliveries when another transport is published. + boolean previousRequiresPreparation = previous != null + && (deferredReplacementLoad || previous.requiresPreparationForReplacement() + || previous.requiresRedisRetirement()); BackendProxyHandler replacement = new BackendProxyHandler(this, backendProcessedVoteCache); - try { - replacement.load(); - replacement.validateTransport(); - if (previous != null) previous.completeRedisHandoff(replacement); - } catch (RuntimeException failure) { - replacement.close(); - throw failure; + if (!deferredReplacementLoad) { + try { + replacement.loadForReplacement(); + } catch (RuntimeException failure) { + replacement.close(); + throw failure; + } + } + BackendProxyRestart restart = new BackendProxyRestart(previous, replacement, false, previousRequiresPreparation); + restart.replacementMethod = replacementMethod; + restart.replacementLoadDeferred = deferredReplacementLoad; + return restart; + } + + public void validateBackendProxyHandlerRestart(BackendProxyRestart restart, long validationDeadlineNanos) { + if (restart == null) throw new IllegalArgumentException("Backend proxy restart is required"); + // Disabling always stops a live predecessor's presence transport. That stop + // can perform network/backend work for MQTT, MySQL, and socket transports + // even when they have no HTTP/plugin-message handoff to prepare. Keep it on + // the Control worker rather than falling through to Bukkit publication. + if (restart.disabled && restart.previous != null && !restart.presenceStoppedForDisablePreparation) { + restart.presenceStoppedForDisablePreparation = true; + restart.previous.preparePresenceForDisable(validationDeadlineNanos); + } + if (restart.previousRequiresPreparation && !restart.previousPrepared) { + // Preparation can close a retrying enrollment transport before a bounded + // worker join fails. Mark the restart first so abort/await still owns the + // restoration path after a partially completed preparation. + restart.previousPrepared = true; + BungeeMethod replacementMethod = restart.replacement == null ? null : restart.effectiveReplacementMethod(); + if (restart.replacement != null) restart.replacement.beginPreparedHttpHandoff(); + if (!restart.previous.prepareForReplacement(replacementMethod, validationDeadlineNanos)) + throw new IllegalStateException("Previous proxy transport could not be prepared for replacement"); + if (replacementMethod == BungeeMethod.HTTP) + restart.previous.reservePreparedHttpHandoff(restart.replacement); + } + if (restart.replacement != null && restart.replacementLoadDeferred) { + restart.replacement.loadForReplacement(); + restart.replacementLoadDeferred = false; + } + if (restart.replacement != null) restart.replacement.validateTransport(validationDeadlineNanos); + // Same-Redis handoff fences callbacks and joins the retiring listener. Those + // waits are bounded, but they must never run on the Bukkit publication task. + // The staged replacement still keeps all inbound callbacks behind its + // publication gate, so doing this on the Control worker cannot expose it early. + if (restart.previous != null && restart.replacement != null + && restart.previous.requiresRedisHandoff(restart.replacement)) { + synchronized (restart) { + if (restart.redisHandoffCompleted) return; + if (restart.redisHandoffInProgress) + throw new IllegalStateException("Redis handoff validation is already in progress"); + restart.redisHandoffInProgress = true; + } + boolean abandonAfterHandoff; + try { + restart.previous.completeRedisHandoff(restart.replacement); + } catch (RuntimeException handoffFailure) { + synchronized (restart) { + restart.redisHandoffInProgress = false; + restart.notifyAll(); + } + throw handoffFailure; + } + synchronized (restart) { + restart.redisHandoffInProgress = false; + restart.redisHandoffCompleted = true; + abandonAfterHandoff = restart.abandonmentRequested; + restart.notifyAll(); + } + if (abandonAfterHandoff) abortBackendProxyHandlerRestart(restart); + } + } + + public void completeBackendProxyHandlerRestart(BackendProxyRestart restart) { + synchronized (this) { + if (restart == null || restart.finished) throw new IllegalStateException("Backend proxy restart is no longer active"); + boolean requiresRedisHandoff = restart.previous != null && restart.replacement != null + && restart.previous.requiresRedisHandoff(restart.replacement); + boolean abandonmentRequested; + synchronized (restart) { + if (requiresRedisHandoff && (!restart.redisHandoffCompleted || restart.redisHandoffInProgress)) { + throw new IllegalStateException("Redis handoff must complete during validation before publication"); + } + abandonmentRequested = restart.abandonmentRequested; + } + if (abandonmentRequested) { + abortBackendProxyHandlerRestart(restart); + return; + } + if (backendProxyHandler != restart.previous) throw new IllegalStateException("Backend proxy handler changed during restart"); + if (restart.disabled) { + if (restart.previous != null && !restart.presenceStoppedForDisablePreparation) + throw new IllegalStateException("Backend proxy disable must be prepared before Bukkit publication"); + if (restart.previous != null && !restart.previous.commitPreparedDisable()) + throw new IllegalStateException("Backend proxy transport accepted a delivery while disabling"); + backendProxyHandler = null; + BackendControlAutoEnrollment enrollment = backendControlAutoEnrollment; + backendControlAutoEnrollment = null; + restart.finished = true; + restart.published = true; + closePublishedPreviousBackendProxyHandler(restart.previous, "after disabling"); + if (enrollment != null) { + try { + enrollment.close(); + } catch (RuntimeException cleanupFailure) { + getLogger().warning("Backend Control enrollment did not stop cleanly after disabling"); + debug(cleanupFailure); + } + } + return; + } + publishBackendProxyHandler(restart.previous, restart.replacement); + // Keep the prepared queue owned by the previous handler until every fallible + // publication step succeeds. Admission performs no network I/O. + try { + if (restart.previous != null) restart.previous.completeHttpHandoff(restart.replacement); + } catch (RuntimeException handoffFailure) { + backendProxyHandler = restart.previous; + restart.replacement.abortStagedInboundTo(restart.previous); + if (restart.requiresWorkerRollback()) { + // The Control worker owns exclusive socket/MQTT teardown and + // predecessor reconnection. Publication has only restored the + // Bukkit-visible handler and fenced staged inbound callbacks. + throw handoffFailure; + } + // A validated Redis promotion may already own accepted, deduplicated + // replay envelopes even though inbound publication has not opened. Move + // those envelopes back before closing the staged replacement, whose + // normal close path deliberately clears its replay queue. + if (restart.redisHandoffCompleted && restart.previous != null) { + try { + restart.previous.restoreAfterFailedReplacement(restart.replacement); + restart.previous.refreshPresenceAfterFailedReplacement(); + } catch (RuntimeException restorationFailure) { + handoffFailure.addSuppressed(restorationFailure); + // Retain the staged replacement and its replay queue for the caller's + // subsequent rollback retry rather than clearing accepted envelopes. + throw handoffFailure; + } + } + if (requiresAsyncStagedReplacementClose(restart)) + closeStagedRedisReplacementAsync(restart.replacement); + else try { + restart.replacement.close(); + } catch (RuntimeException closeFailure) { + handoffFailure.addSuppressed(closeFailure); + } + if (!restart.redisHandoffCompleted) { + try { + restart.previous.restoreAfterFailedReplacement(); + restart.previous.refreshPresenceAfterFailedReplacement(); + } catch (RuntimeException restorationFailure) { + handoffFailure.addSuppressed(restorationFailure); + } + } + restart.finished = true; + throw handoffFailure; + } + restart.replacement.activateInboundMessages(); + restart.replacement.replayRedisAfterHandoffPublication(); + restart.finished = true; + restart.published = true; + closePublishedPreviousBackendProxyHandler(restart.previous, "after publication"); } - backendProxyHandler = replacement; - if (previous != null) previous.close(); try { refreshBackendControlAutoEnrollment(); } catch (IOException e) { @@ -1254,17 +1492,146 @@ public synchronized void restartBackendProxyHandler() { } } + /** Retires network-backed predecessors without blocking the Bukkit publication callback. */ + void closePublishedPreviousBackendProxyHandler(BackendProxyHandler previous, String phase) { + if (previous == null) return; + Thread cleanup = new Thread(() -> closePublishedPreviousBackendProxyHandlerNow(previous, phase), + "VotingPlugin-Retired-Backend"); + cleanup.setDaemon(true); + cleanup.start(); + } + + private void closePublishedPreviousBackendProxyHandlerNow(BackendProxyHandler previous, String phase) { + try { + previous.close(); + } catch (RuntimeException cleanupFailure) { + // Publication is committed. Cleanup failure must not roll back the only live handler. + getLogger().warning("Previous backend proxy handler did not stop cleanly " + phase); + debug(cleanupFailure); + } + } + + /** Publishes the handler before opening any transport callback or presence gate. */ + void publishBackendProxyHandler(BackendProxyHandler previous, BackendProxyHandler replacement) { + backendProxyHandler = replacement; + try { + replacement.activatePresenceReporting(); + } catch (RuntimeException activationFailure) { + backendProxyHandler = previous; + replacement.abortStagedInboundTo(previous); + throw activationFailure; + } + } + + /** Returns false once publication committed and can no longer be rolled back as a failed apply. */ + public synchronized boolean requestBackendProxyHandlerRestartAbandonment(BackendProxyRestart restart) { + if (restart == null) return true; + synchronized (restart) { + if (restart.published) return false; + restart.abandonmentRequested = true; + restart.notifyAll(); + } + return true; + } + + public synchronized void abortBackendProxyHandlerRestart(BackendProxyRestart restart) { + if (restart == null || restart.finished) return; + synchronized (restart) { + // Validation owns the retiring Redis listener until its handoff either + // succeeds or fails. Deferring rollback closes the race where abort could + // observe a false completion flag and leave that listener fenced. + if (restart.redisHandoffInProgress) { + restart.abandonmentRequested = true; + return; + } + } + RuntimeException cleanupFailure = null; + if (restart.redisHandoffCompleted && restart.previous != null && restart.replacement != null) { + // Preserve the promoted replacement's pre-publication replay queue before + // its close fences and clears it, then retire that staged listener. + try { + restart.previous.restoreAfterFailedReplacement(restart.replacement); + } catch (RuntimeException failure) { + cleanupFailure = failure; + } + } + if (restart.replacement != null) { + try { + restart.replacement.abortStagedInboundTo(restart.previous); + if (requiresAsyncStagedReplacementClose(restart)) closeStagedRedisReplacementAsync(restart.replacement); + else restart.replacement.close(); + } catch (RuntimeException failure) { + if (cleanupFailure == null) cleanupFailure = failure; + else cleanupFailure.addSuppressed(failure); + } + } + if (backendProxyHandler == restart.previous && restart.previous != null + && (restart.previousPrepared || restart.redisHandoffCompleted + || restart.previous.getMethod() == BungeeMethod.PLUGINMESSAGING)) { + if (!restart.redisHandoffCompleted) try { + restart.previous.restoreAfterFailedReplacement(); + } catch (RuntimeException failure) { + if (cleanupFailure == null) cleanupFailure = failure; + else cleanupFailure.addSuppressed(failure); + } + } + if (backendProxyHandler == restart.previous && restart.previous != null + && restart.presenceStoppedForDisablePreparation) { + try { + restart.previous.restorePresenceAfterFailedDisablePreparation(); + } catch (RuntimeException failure) { + if (cleanupFailure == null) cleanupFailure = failure; + else cleanupFailure.addSuppressed(failure); + } + } + restart.finished = true; + if (cleanupFailure != null) throw cleanupFailure; + } + + private boolean requiresAsyncStagedReplacementClose(BackendProxyRestart restart) { + return restart.redisHandoffCompleted || restart.replacement.getMethod() == BungeeMethod.REDIS; + } + + /** A staged Redis listener can spend its bounded join timeout in close(); abort runs on Bukkit. */ + private void closeStagedRedisReplacementAsync(BackendProxyHandler replacement) { + Thread cleanup = new Thread(() -> { + try { + replacement.close(); + } catch (RuntimeException cleanupFailure) { + getLogger().warning("Staged Redis backend proxy handler did not stop cleanly after rollback"); + debug(cleanupFailure); + } + }, "VotingPlugin-Staged-Redis-Rollback"); + cleanup.setDaemon(true); + cleanup.start(); + } + + public void awaitBackendProxyHandlerRollback(BackendProxyRestart restart, long deadlineNanos) { + if (restart != null && restart.previousPrepared) { + restart.previous.awaitRestoreAfterFailedReplacement(deadlineNanos); + } + } + /** Applies only proxy communication settings without reloading unrelated Bukkit configuration. */ public synchronized void reloadBackendProxyMethodFromControl() { + reloadBackendProxyMethodSettingsFromControl(); + restartBackendProxyHandler(); + } + + /** Bukkit-side narrow preparation for a proxy-method Control APPLY. */ + public synchronized BackendProxyRestart prepareBackendProxyMethodRestartFromControl() { + reloadBackendProxyMethodSettingsFromControl(); + return prepareBackendProxyHandlerRestart(); + } + + private void reloadBackendProxyMethodSettingsFromControl() { bungeeSettings.reloadData(); getOptions().setServer(bungeeSettings.getServer()); updateAdvancedCoreHook(); - restartBackendProxyHandler(); } /** Keeps one plugin-message listener for the plugin lifetime and atomically swaps its active backend handler. */ public synchronized void activateBackendPluginMessageHandler(GlobalMessageHandler target) { - backendPluginMessageTarget.set(target); if (backendPluginMessageRelay == null) { backendPluginMessageRelay = new PluginMessageHandler() { @Override @@ -1273,8 +1640,16 @@ public void onReceive(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope envel if (current != null) current.onMessage(envelope); } }; - getPluginMessaging().add(backendPluginMessageRelay); } + com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage current = getPluginMessaging(); + if (backendPluginMessageRelayOwner != current) { + if (backendPluginMessageRelayOwner != null) { + backendPluginMessageRelayOwner.getPluginMessages().remove(backendPluginMessageRelay); + } + current.add(backendPluginMessageRelay); + backendPluginMessageRelayOwner = current; + } + backendPluginMessageTarget.set(target); } public void deactivateBackendPluginMessageHandler(GlobalMessageHandler target) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java index 8395c38b4..8847eeebb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandler.java @@ -25,6 +25,7 @@ import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransportManager; import com.bencodez.votingplugin.backendproxy.voteparty.BackendVotePartySync; import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginWire; import lombok.Getter; @@ -39,6 +40,11 @@ public class BackendProxyHandler implements Listener { private final BackendGlobalDataSync globalDataSync; private BackendPresenceManager presenceManager; + private boolean presenceReportingActivated; + private final Object inboundPublication = new Object(); + private boolean inboundPublished; + private boolean inboundAborted; + private BackendProxyHandler inboundRollbackTarget; private BackendVotePartySync votePartySync; private BackendProxyMessageRouter messageRouter; @@ -62,12 +68,26 @@ public BackendProxyHandler(VotingPluginMain plugin, ProcessedVoteCache processed * Loads the configured backend/proxy communication components. */ public void load() { + load(true); + } + + /** Loads a replacement without announcing a new presence generation before publication. */ + public void loadForReplacement() { + load(false); + } + + private void load(boolean activatePresenceReporting) { plugin.debug("Loading backend proxy handler"); method = BungeeMethod.getByName(plugin.getBungeeSettings().getBungeeMethod()); plugin.getLogger().info("Using BungeeMethod: " + method.toString()); globalDataSync.load(); globalMessageHandler = new GlobalMessageHandler() { + @Override + public void onMessage(JsonEnvelope envelope) { + BackendProxyHandler.this.dispatchIncomingAfterPublication(envelope, () -> super.onMessage(envelope)); + } + @Override public void sendMessage(JsonEnvelope envelope) { transportManager.send(envelope); @@ -79,20 +99,40 @@ public void sendMessage(JsonEnvelope envelope) { messageRouter = new BackendProxyMessageRouter(plugin, presenceManager, globalDataSync, votePartySync, processedVoteCache); messageRouter.register(globalMessageHandler, method); - transportManager.start(method, globalMessageHandler); + transportManager.start(method, globalMessageHandler, activatePresenceReporting); if (plugin.getOptions().getServer().equalsIgnoreCase("pleaseset")) { plugin.getLogger().warning("Server name for bungee voting is not set, please set it"); } - presenceManager.start(); + if (activatePresenceReporting) { + activatePresenceReporting(); + activateInboundMessages(); + } + } + + /** Starts presence only after a staged handler reaches the atomic publication boundary. */ + public void activatePresenceReporting() { + if (presenceManager != null && !presenceReportingActivated) { + presenceManager.start(); + presenceReportingActivated = true; + } + // Presence startup can throw while scheduling its heartbeat. Keep inbound + // HTTP callbacks behind the publication barrier until every fallible part of + // the replacement is active, so rollback cannot race a queued callback. + transportManager.activateAfterPublication(); } /** * Closes backend/proxy components and persists cached proxy state. */ public void close() { - if (presenceManager != null) { + synchronized (inboundPublication) { + if (!inboundPublished && !inboundAborted) inboundAborted = true; + inboundPublication.notifyAll(); + } + if (presenceManager != null && presenceReportingActivated) { presenceManager.stop(); + presenceReportingActivated = false; } transportManager.close(); if (votePartySync != null) { @@ -101,26 +141,207 @@ public void close() { globalDataSync.close(); } - /** Releases a same-method subscriber/listener before its replacement starts. */ - public void prepareForReplacement(BungeeMethod replacementMethod) { - if (method == replacementMethod && method != BungeeMethod.PLUGINMESSAGING && method != BungeeMethod.REDIS) { + /** Opens inbound dispatch only after the replacement and all handoffs are committed. */ + public void activateInboundMessages() { + synchronized (inboundPublication) { + if (inboundAborted) return; + inboundPublished = true; + inboundPublication.notifyAll(); + } + } + + /** Routes an already accepted staged callback through the restored predecessor on rollback. */ + public void abortStagedInboundTo(BackendProxyHandler previous) { + synchronized (inboundPublication) { + if (inboundPublished || inboundAborted) return; + inboundRollbackTarget = previous; + inboundAborted = true; + inboundPublication.notifyAll(); + } + } + + void dispatchIncomingAfterPublication(JsonEnvelope envelope, Runnable localDispatch) { + BackendProxyHandler rollbackTarget; + synchronized (inboundPublication) { + while (!inboundPublished && !inboundAborted) { + try { + inboundPublication.wait(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + rollbackTarget = inboundPublished ? null : inboundRollbackTarget; + if (!inboundPublished && rollbackTarget == null) return; + } + if (rollbackTarget != null) { + GlobalMessageHandler rollbackHandler = rollbackTarget.globalMessageHandler; + if (rollbackHandler != null) rollbackHandler.onMessage(envelope); + return; + } + // Global-data checks perform synchronous SQL and already run on their own + // timer worker. Explicitly dispatch an inbound wake-up asynchronously too: + // plugin messaging can invoke this method on Bukkit's primary thread. + if (VotingPluginWire.SUB_BUNGEE_TIME_CHANGE.equals(envelope.getSubChannel())) { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, localDispatch); + return; + } + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, localDispatch); + } + + /** Returns whether replacement preparation must preserve accepted deliveries. */ + public boolean requiresPreparationForReplacement() { + return method == BungeeMethod.HTTP || method == BungeeMethod.PLUGINMESSAGING + || transportManager.hasPendingAsyncHandoff() || transportManager.hasPendingRedisReplay(); + } + + /** Prepares HTTP state or waits off-thread for an earlier cross-transport handoff. */ + public boolean prepareForReplacement(BungeeMethod replacementMethod) { + return prepareForReplacement(replacementMethod, + System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(25)); + } + + public boolean prepareForReplacement(BungeeMethod replacementMethod, long deadlineNanos) { + // SocketHandler swallows a listener bind failure. A same-method replacement + // must therefore retire its listener before the staged handler is started; + // rollback recreates this prepared transport if validation later fails. + if (method == BungeeMethod.SOCKETS && replacementMethod == BungeeMethod.SOCKETS) { + transportManager.prepareForReplacement(); + return true; + } + if (method == BungeeMethod.MQTT && replacementMethod == BungeeMethod.MQTT) { + // A duplicate MQTT ClientID disconnects the live broker session, so stage + // only after retiring the predecessor and restore it on validation rollback. + transportManager.prepareForReplacement(); + return true; + } + if (method == BungeeMethod.HTTP) { transportManager.prepareForReplacement(); + return true; } + if (method == BungeeMethod.PLUGINMESSAGING) { + transportManager.prepareAsyncHandoffForReplacement(deadlineNanos); + return true; + } + if (method == BungeeMethod.REDIS && replacementMethod != BungeeMethod.REDIS) { + if (transportManager.hasPendingAsyncHandoff()) + transportManager.prepareAsyncHandoffForReplacement(deadlineNanos); + return transportManager.prepareRedisReplayTransition(replacementMethod, deadlineNanos); + } + if (method == BungeeMethod.REDIS) { + // Same-Redis retirement installs its send fence during the bounded + // off-thread handoff. Returning true here lets the staged replacement + // buffer its own sends until the predecessor FIFO is admitted at Bukkit + // publication. + return true; + } + if (!transportManager.hasPendingAsyncHandoff()) return false; + transportManager.prepareAsyncHandoffForReplacement(deadlineNanos); + return true; + } + + /** Atomically fences new sends only when disabling cannot discard prepared HTTP messages. */ + public boolean commitPreparedDisable() { + return transportManager.commitPreparedDisable(); + } + + /** Publishes the final presence update while fencing other sends before transport preparation. */ + public void preparePresenceForDisable() { + preparePresenceForDisableInternal(null); + } + + /** Publishes the final presence update before the caller's validation deadline. */ + public void preparePresenceForDisable(long deadlineNanos) { + preparePresenceForDisableInternal(deadlineNanos); + } + + private void preparePresenceForDisableInternal(Long deadlineNanos) { + transportManager.beginPreparedDisable(); + if (presenceManager != null && presenceReportingActivated) { + // stopForDisable may reject when the transport cannot accept the final + // presence update. Mark this inactive first so rollback can start it again. + presenceReportingActivated = false; + if (deadlineNanos == null) presenceManager.stopForDisable(); + else presenceManager.stopForDisable(deadlineNanos); + } + } + + /** Restores delivery and presence when a prepared disable is abandoned. */ + public void restorePresenceAfterFailedDisablePreparation() { + transportManager.cancelPreparedDisable(); + activatePresenceReporting(); + } + + public void beginPreparedHttpHandoff() { + transportManager.beginPreparedHttpHandoff(); + } + + /** Reserves staged HTTP capacity before publication can admit replacement sends. */ + public void reservePreparedHttpHandoff(BackendProxyHandler replacement) { + transportManager.reservePreparedTransportHandoff(replacement.transportManager); + } + + /** Restores a prepared HTTP transport when its replacement fails validation. */ + public void restoreAfterFailedReplacement() { + transportManager.restoreAfterFailedReplacement(); + } + + /** Restores a failed same-Redis predecessor without discarding its replacement's replay FIFO. */ + public void restoreAfterFailedReplacement(BackendProxyHandler failedReplacement) { + transportManager.restoreAfterFailedReplacement( + failedReplacement == null ? null : failedReplacement.transportManager); + } + + /** Reasserts the old handler with a fresh presence generation after rollback. */ + public void refreshPresenceAfterFailedReplacement() { + if (presenceManager != null && presenceReportingActivated) { + presenceManager.stop(); + presenceManager.start(); + } + } + + public void awaitRestoreAfterFailedReplacement(long deadlineNanos) { + transportManager.awaitPreparedTransportRestoration(deadlineNanos); } /** Fails a configuration apply when its selected transport did not initialize. */ public void validateTransport() { + validateTransport(System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(25)); + } + + /** Validates transport startup without extending the caller's existing deadline. */ + public void validateTransport(long deadlineNanos) { if (method == null || globalMessageHandler == null || presenceManager == null) { throw new IllegalStateException("Backend proxy handler initialization failed"); } - transportManager.validate(); + transportManager.validate(deadlineNanos); } /** Completes the no-loss/no-duplicate same-Redis subscriber handoff after validation. */ public void completeRedisHandoff(BackendProxyHandler replacement) { - if (method != BungeeMethod.REDIS || replacement.method != BungeeMethod.REDIS) return; - transportManager.closeRedisForHandoff(); - replacement.transportManager.activateRedisAfterHandoff(); + if (!requiresRedisHandoff(replacement)) return; + transportManager.completeRedisHandoff(replacement.transportManager); + } + + /** Returns whether this handler owns a Redis listener whose shutdown can block. */ + public boolean requiresRedisRetirement() { + return method == BungeeMethod.REDIS; + } + + /** Returns whether this replacement needs the bounded same-Redis retirement path. */ + public boolean requiresRedisHandoff(BackendProxyHandler replacement) { + return replacement != null && method == BungeeMethod.REDIS && replacement.method == BungeeMethod.REDIS; + } + + /** Replays Redis handoff deliveries only after inbound publication is open. */ + public void replayRedisAfterHandoffPublication() { + transportManager.replayRedisAfterHandoffPublication(); + } + + /** Forwards messages buffered while the previous transport was fenced. */ + public void completeHttpHandoff(BackendProxyHandler replacement) { + if (replacement == null) return; + transportManager.completePreparedTransportHandoff(replacement.transportManager); } public void playerOnline(String playerName, String uuid) { @@ -136,14 +357,15 @@ public void playerOffline(String playerName) { } public void reloadPresenceReporting() { - if (presenceManager != null) { + if (presenceManager != null && presenceReportingActivated) { presenceManager.reload(); } } public void disablePresenceReporting() { - if (presenceManager != null) { + if (presenceManager != null && presenceReportingActivated) { presenceManager.stop(); + presenceReportingActivated = false; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java index 919fb8077..5029b7e4d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/cache/ProcessedVoteCache.java @@ -25,6 +25,7 @@ public class ProcessedVoteCache { private final LinkedHashMap processedRedisDeliveries = new LinkedHashMap<>(); private final LinkedHashMap legacyRedisDeliveries = new LinkedHashMap<>(); private long legacyRedisDeliveryBytes; + private boolean legacyRedisHandoffOverflowed; private Object activeRedisSubscriber; private Object standbyRedisSubscriber; @@ -82,14 +83,18 @@ public synchronized boolean reserveRedisDelivery(String deliveryId) { return true; } - public synchronized void registerRedisSubscriber(Object subscriber) { + public synchronized boolean registerRedisSubscriber(Object subscriber) { if (activeRedisSubscriber == null) { activeRedisSubscriber = subscriber; + return true; } else if (activeRedisSubscriber != subscriber) { standbyRedisSubscriber = subscriber; legacyRedisDeliveries.clear(); legacyRedisDeliveryBytes = 0; + legacyRedisHandoffOverflowed = false; + return false; } + return true; } /** Returns true only for the active subscriber and counts its legacy delivery during overlap. */ @@ -107,12 +112,16 @@ public synchronized boolean reserveLegacyRedisDelivery(Object subscriber, String && legacyRedisDeliveryBytes <= MAX_LEGACY_REDIS_TOTAL_BYTES - bytes) { legacyRedisDeliveries.put(signature, 1); legacyRedisDeliveryBytes += bytes; - } + } else legacyRedisHandoffOverflowed = true; } } return true; } + public synchronized boolean isLegacyRedisHandoffOverflowed() { + return legacyRedisHandoffOverflowed; + } + public synchronized void activateRedisSubscriber(Object subscriber) { if (standbyRedisSubscriber != subscriber) { throw new IllegalStateException("Redis replacement subscriber is not registered"); @@ -121,6 +130,15 @@ public synchronized void activateRedisSubscriber(Object subscriber) { standbyRedisSubscriber = null; } + /** Restores the retired active listener after a promoted replacement is rolled back. */ + public synchronized void restoreRedisSubscriber(Object subscriber) { + activeRedisSubscriber = subscriber; + standbyRedisSubscriber = null; + legacyRedisDeliveries.clear(); + legacyRedisDeliveryBytes = 0; + legacyRedisHandoffOverflowed = false; + } + /** Consumes one matching delivery processed by the previous active subscriber. */ public synchronized boolean consumeLegacyRedisDelivery(String signature) { Integer count = legacyRedisDeliveries.get(signature); @@ -135,6 +153,7 @@ public synchronized boolean consumeLegacyRedisDelivery(String signature) { public synchronized void finishRedisHandoff() { legacyRedisDeliveries.clear(); legacyRedisDeliveryBytes = 0; + legacyRedisHandoffOverflowed = false; } public synchronized void unregisterRedisSubscriber(Object subscriber) { @@ -142,6 +161,7 @@ public synchronized void unregisterRedisSubscriber(Object subscriber) { standbyRedisSubscriber = null; legacyRedisDeliveries.clear(); legacyRedisDeliveryBytes = 0; + legacyRedisHandoffOverflowed = false; } if (activeRedisSubscriber == subscriber) activeRedisSubscriber = null; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java index 29c24a7db..febe99c28 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSync.java @@ -4,9 +4,12 @@ import java.time.ZoneOffset; import java.util.HashMap; import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; import com.bencodez.advancedcore.api.time.TimeType; @@ -29,6 +32,8 @@ public class BackendGlobalDataSync { private final VotingPluginMain plugin; private final Consumer sender; + private final AtomicBoolean forceUpdateInProgress = new AtomicBoolean(false); + private final Set timeChangesInProgress = ConcurrentHashMap.newKeySet(); @Getter private GlobalDataHandler globalDataHandler; @@ -46,26 +51,40 @@ public void checkGlobalData() { } HashMap data = globalDataHandler.getExact(plugin.getBungeeSettings().getServer()); - if (data.containsKey("ForceUpdate") && checkGlobalDataTimeValue(data.get("ForceUpdate"))) { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().clearCacheBasic(); + if (data.containsKey("ForceUpdate") && checkGlobalDataTimeValue(data.get("ForceUpdate")) + && forceUpdateInProgress.compareAndSet(false, true)) { + String serverName = plugin.getBungeeSettings().getServer(); + try { + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + plugin.getMysql().clearCacheBasic(); + } + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> { + try { + plugin.getUserManager().getDataManager().clearCache(); + plugin.setUpdate(true); + plugin.update(); + } catch (RuntimeException failure) { + forceUpdateInProgress.set(false); + plugin.debug(failure); + return; + } + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> clearForceUpdateFlag(serverName)); + } catch (RuntimeException failure) { + forceUpdateInProgress.set(false); + plugin.debug(failure); + } + }); + } catch (RuntimeException failure) { + forceUpdateInProgress.set(false); + plugin.debug(failure); } - plugin.getUserManager().getDataManager().clearCache(); - plugin.setUpdate(true); - plugin.update(); - globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), "ForceUpdate", false); } - boolean forceUpdate = checkGlobalDataTime(TimeType.MONTH, data); - forceUpdate |= checkGlobalDataTime(TimeType.WEEK, data); - forceUpdate |= checkGlobalDataTime(TimeType.DAY, data); - - if (forceUpdate) { - HashMap dataToSet = new HashMap<>(); - dataToSet.put("FinishedProcessing", new DataValueBoolean(true)); - dataToSet.put("Processing", new DataValueBoolean(false)); - globalDataHandler.setData(plugin.getBungeeSettings().getServer(), dataToSet); - } + checkGlobalDataTime(TimeType.MONTH, data); + checkGlobalDataTime(TimeType.WEEK, data); + checkGlobalDataTime(TimeType.DAY, data); } public boolean checkGlobalDataTime(TimeType type, HashMap data) { @@ -80,18 +99,58 @@ public boolean checkGlobalDataTime(TimeType type, HashMap dat globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), type.toString(), false); return false; } + if (!timeChangesInProgress.add(type)) { + return false; + } - globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), "Processing", true); + String serverName = plugin.getBungeeSettings().getServer(); + globalDataHandler.setBoolean(serverName, "Processing", true); plugin.debug("Detected time change from bungee: " + type.toString()); - plugin.getTimeChecker().forceChanged(type, false, true, true); - globalDataHandler.setBoolean(plugin.getBungeeSettings().getServer(), type.toString(), false); - - JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished").schema(VotingPluginWire.SCHEMA_VERSION); - builder.put("server", plugin.getBungeeSettings().getServer()); - sender.accept(builder.build()); + try { + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> { + try { + plugin.getTimeChecker().forceChanged(type, false, true, true); + } catch (RuntimeException failure) { + timeChangesInProgress.remove(type); + plugin.debug(failure); + return; + } + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> finishTimeChange(type, serverName)); + } catch (RuntimeException failure) { + timeChangesInProgress.remove(type); + plugin.debug(failure); + } + }); + } catch (RuntimeException failure) { + timeChangesInProgress.remove(type); + plugin.debug(failure); + return false; + } return true; } + private void finishTimeChange(TimeType type, String serverName) { + boolean completed = false; + try { + globalDataHandler.setBoolean(serverName, type.toString(), false); + JsonEnvelope.Builder builder = JsonEnvelope.builder("TimeChangeFinished") + .schema(VotingPluginWire.SCHEMA_VERSION); + builder.put("server", serverName); + sender.accept(builder.build()); + completed = true; + } finally { + timeChangesInProgress.remove(type); + if (completed && timeChangesInProgress.isEmpty()) { + HashMap dataToSet = new HashMap<>(); + dataToSet.put("FinishedProcessing", new DataValueBoolean(true)); + dataToSet.put("Processing", new DataValueBoolean(false)); + globalDataHandler.setData(serverName, dataToSet); + } + } + } + public boolean checkGlobalDataTimeValue(DataValue data) { if (data.isBoolean()) { return data.getBoolean(); @@ -99,6 +158,14 @@ public boolean checkGlobalDataTimeValue(DataValue data) { return Boolean.valueOf(data.getString()); } + private void clearForceUpdateFlag(String serverName) { + try { + globalDataHandler.setBoolean(serverName, "ForceUpdate", false); + } finally { + forceUpdateInProgress.set(false); + } + } + public void load() { if (!plugin.getBungeeSettings().isGloblalDataEnabled()) { return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/presence/BackendPresenceManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/presence/BackendPresenceManager.java index a82c5551a..91c601d81 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/presence/BackendPresenceManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/presence/BackendPresenceManager.java @@ -3,9 +3,12 @@ import java.util.ArrayList; import java.util.List; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -34,6 +37,8 @@ public class BackendPresenceManager { private final Object lifecycleLock = new Object(); private boolean reporting; + /** Invalidates delayed lifecycle callbacks when a fresh incarnation starts. */ + private long lifecycleGeneration; private String server; private UUID incarnationId; private long startedAt; @@ -57,6 +62,7 @@ public void start() { } String configuredServer = plugin.getBungeeSettings().getServer(); synchronized (lifecycleLock) { + lifecycleGeneration++; long now = System.currentTimeMillis(); incarnationId = UUID.randomUUID(); startedAt = now; @@ -67,24 +73,90 @@ public void start() { lastResyncRequestAtNanos = 0L; lastSnapshotRequestId = null; lastSnapshotRequestAtNanos = 0L; - send(VotingPluginWire.backendStarted(server, incarnationId, startedAt, now)); - send(VotingPluginWire.backendHeartbeat(server, incarnationId, startedAt, nextTimestamp())); - if (heartbeatTask != null) { heartbeatTask.cancel(false); } - heartbeatTask = plugin.getTimer().scheduleAtFixedRate(new Runnable() { - @Override - public void run() { - sendHeartbeat(); - } - }, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS); + try { + heartbeatTask = plugin.getTimer().scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + sendHeartbeat(); + } + }, HEARTBEAT_SECONDS, HEARTBEAT_SECONDS, TimeUnit.SECONDS); + seedOnlinePlayers(); + } catch (RuntimeException failure) { + if (heartbeatTask != null) heartbeatTask.cancel(false); + heartbeatTask = null; + reporting = false; + server = null; + incarnationId = null; + throw failure; + } + send(VotingPluginWire.backendStarted(server, incarnationId, startedAt, now)); + send(VotingPluginWire.backendHeartbeat(server, incarnationId, startedAt, nextTimestamp())); } - seedOnlinePlayers(); } public void stop() { + stop(false); + } + + /** Stops presence and propagates rejection when a configuration disable must be transactional. */ + public void stopForDisable() { + stopForDisable(System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + } + + /** Stops presence before the caller's transactional validation deadline. */ + public void stopForDisable(long deadlineNanos) { + // Plugin-message transport ultimately calls Bukkit's sendPluginMessage API, + // which is primary-thread-only. Control validation runs on its worker, so + // marshal the transactional stopped-presence send before returning the + // result to that worker. Unit-test and shutdown contexts without a server + // retain the direct path used by the non-Control lifecycle. + if (method == BungeeMethod.PLUGINMESSAGING && plugin != null && plugin.getServer() != null + && !plugin.getServer().isPrimaryThread()) { + long expectedGeneration; + synchronized (lifecycleLock) { + expectedGeneration = lifecycleGeneration; + } + CompletableFuture scheduled = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().executeOrScheduleSync(plugin, () -> { + try { + stop(true, expectedGeneration); + scheduled.complete(null); + } catch (Throwable failure) { + scheduled.completeExceptionally(failure); + } + }); + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) throw new TimeoutException("Backend stopped presence deadline expired"); + scheduled.get(remaining, TimeUnit.NANOSECONDS); + return; + } catch (Exception failure) { + scheduled.cancel(false); + Throwable cause = failure instanceof ExecutionException && failure.getCause() != null + ? failure.getCause() : failure; + if (cause instanceof RuntimeException runtime) throw runtime; + throw new IllegalStateException("Backend stopped presence could not run on the Bukkit thread", cause); + } + } + stop(true); + } + + private void stop(boolean requireStoppedDelivery) { + stop(requireStoppedDelivery, -1L); + } + + /** + * Stops only the generation captured by a delayed callback. A cancelled future + * does not necessarily cancel a task already queued on the Bukkit scheduler. + */ + private void stop(boolean requireStoppedDelivery, long expectedGeneration) { synchronized (lifecycleLock) { + if (expectedGeneration >= 0L && lifecycleGeneration != expectedGeneration) { + return; + } String activeServer = server; UUID activeIncarnationId = incarnationId; long activeStartedAt = startedAt; @@ -95,16 +167,21 @@ public void stop() { heartbeatTask.cancel(false); heartbeatTask = null; } - if (wasReporting && activeServer != null && activeIncarnationId != null) { - send(VotingPluginWire.backendStopped(activeServer, activeIncarnationId, activeStartedAt, - nextTimestamp())); + try { + if (wasReporting && activeServer != null && activeIncarnationId != null) { + JsonEnvelope stopped = VotingPluginWire.backendStopped(activeServer, activeIncarnationId, + activeStartedAt, nextTimestamp()); + if (requireStoppedDelivery) globalMessageHandler.sendMessage(stopped); + else send(stopped); + } + } finally { + incarnationId = null; + lastResyncRequestId = null; + lastResyncRequestAtNanos = 0L; + lastSnapshotRequestId = null; + lastSnapshotRequestAtNanos = 0L; + playerSessions.clear(); } - incarnationId = null; - lastResyncRequestId = null; - lastResyncRequestAtNanos = 0L; - lastSnapshotRequestId = null; - lastSnapshotRequestAtNanos = 0L; - playerSessions.clear(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java index 30a0baa00..ab4a7f6ae 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransport.java @@ -10,7 +10,17 @@ public interface BackendProxyTransport { void start(GlobalMessageHandler messageHandler); - void send(JsonEnvelope envelope); + /** + * Starts the transport, optionally allowing startup-only transient failures to + * be retried. Replacement transports disable this so Control validation stays + * fail-fast; ordinary startup retains its recovery loop. + */ + default void start(GlobalMessageHandler messageHandler, boolean retryInitialization) { + start(messageHandler); + } + + /** Returns true only when the delivery was accepted by the transport or its durable queue. */ + boolean send(JsonEnvelope envelope); default void validate() { } @@ -19,5 +29,9 @@ default void prepareForReplacement() { close(); } + /** Activates transport state that must not become visible before handler publication. */ + default void activateAfterPublication() { + } + void close(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java index 4d7b20872..939ea2047 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/BackendProxyTransportManager.java @@ -10,15 +10,37 @@ import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; import com.bencodez.votingplugin.proxy.BungeeMethod; +import com.bencodez.votingplugin.proxy.VotingPluginWire; /** * Selects and owns the active backend-to-proxy transport. */ public class BackendProxyTransportManager { + private static final int MAX_PREPARED_SENDS = 1024; + private static final int MAX_ASYNC_HANDOFF_SENDS = 6144; + private static final int PLUGIN_MESSAGE_HANDOFF_BATCH_SIZE = 32; private final VotingPluginMain plugin; private final ProcessedVoteCache processedVoteCache; private BackendProxyTransport transport; + private BackendProxyTransport preparedTransport; + private BackendProxyTransport retiredTransport; + // The old Redis instance is already stopped after a successful worker-side + // handoff, but keeps its captured handler long enough to restore it if Bukkit + // publication subsequently fails or is abandoned. + private RedisBackendProxyTransport completedRedisHandoffTransport; + private BackendProxyTransportManager forwardingManager; + private final java.util.ArrayDeque preparedSends = new java.util.ArrayDeque<>(); + private final java.util.ArrayDeque asyncHandoffSends = new java.util.ArrayDeque<>(); + private Thread asyncHandoffWorker; + private boolean pluginMessageHandoffScheduled; + private long handoffGeneration; + private boolean preparedSendFence; + private boolean rejectPreparedSends; + private boolean allowStoppedPresenceDuringDisable; + private boolean preparedQueueWarning; + private boolean rejectedSendWarning; + private boolean asyncHandoffRetryWarning; public BackendProxyTransportManager(VotingPluginMain plugin) { this(plugin, new ProcessedVoteCache()); @@ -30,6 +52,10 @@ public BackendProxyTransportManager(VotingPluginMain plugin, ProcessedVoteCache } public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { + start(method, messageHandler, true); + } + + public void start(BungeeMethod method, GlobalMessageHandler messageHandler, boolean retryInitialization) { close(); switch (method) { case MYSQL: @@ -41,6 +67,9 @@ public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { case SOCKETS: transport = new SocketBackendProxyTransport(plugin); break; + case HTTP: + transport = new HttpBackendProxyTransport(plugin); + break; case REDIS: transport = new RedisBackendProxyTransport(plugin, processedVoteCache); break; @@ -50,40 +79,585 @@ public void start(BungeeMethod method, GlobalMessageHandler messageHandler) { default: throw new IllegalArgumentException("Unsupported backend proxy method: " + method); } - transport.start(messageHandler); + transport.start(messageHandler, retryInitialization); } - public void send(JsonEnvelope envelope) { - if (transport != null) { + public synchronized void send(JsonEnvelope envelope) { + if (rejectPreparedSends) { + if (allowStoppedPresenceDuringDisable && envelope != null + && VotingPluginWire.SUB_BACKEND_STOPPED.equals(envelope.getSubChannel())) { + allowStoppedPresenceDuringDisable = false; + BackendProxyTransport stoppingTransport = transport != null ? transport : preparedTransport; + if (stoppingTransport == null || !stoppingTransport.send(envelope)) + throw new IllegalStateException("Backend stopped presence was not accepted before disabling transport"); + return; + } + if (!rejectedSendWarning) { + rejectedSendWarning = true; + plugin.getLogger().severe("Backend proxy transport is disabled; delivery was not accepted"); + } + } else if (preparedSendFence) { + acceptPreparedSend(envelope); + } else if (forwardingManager != null) { + forwardingManager.send(envelope); + } else if (hasPendingAsyncHandoff()) { + acceptQueuedTransportSend(envelope); + } else if (transport instanceof PluginMessagingBackendProxyTransport) { + acceptQueuedTransportSend(envelope); + } else if (transport != null) { transport.send(envelope); + } else if (preparedTransport != null) { + acceptPreparedSend(envelope); + } + } + + private void acceptPreparedSend(JsonEnvelope envelope) { + if (preparedSends.size() < MAX_PREPARED_SENDS) { + preparedSends.addLast(envelope); + } else if (!preparedQueueWarning) { + preparedQueueWarning = true; + plugin.getLogger().severe("Backend proxy replacement handoff queue is full; delivery was not accepted"); } } - public void close() { + public void activateAfterPublication() { + if (transport != null) transport.activateAfterPublication(); + } + + public synchronized void close() { + handoffGeneration++; + if (asyncHandoffWorker != null) asyncHandoffWorker.interrupt(); + asyncHandoffWorker = null; + pluginMessageHandoffScheduled = false; + asyncHandoffSends.clear(); + notifyAll(); if (transport != null) { transport.close(); transport = null; } + if (preparedTransport != null) { + preparedTransport.close(); + preparedTransport = null; + } + if (retiredTransport != null) { + BackendProxyTransport retired = retiredTransport; + if (retired instanceof RedisBackendProxyTransport redis) { + // A failed Redis listener shutdown can spend its bounded join timeout in + // close(). Publication calls the predecessor's close on Bukkit, so retry + // that fenced cleanup independently instead of stalling publication. + retiredTransport = null; + closeRetiredRedisAsync(redis); + } else try { + retired.close(); + retiredTransport = null; + } catch (RuntimeException cleanupFailure) { + // This transport has already been fenced and replaced. Keep its handle + // for another cleanup attempt, but never fail or close the live replacement. + if (plugin != null) { + plugin.getLogger().warning("Retired backend proxy transport did not stop cleanly"); + plugin.debug(cleanupFailure); + } + } + } + completedRedisHandoffTransport = null; + if (forwardingManager == null) preparedSends.clear(); + } + + /** Retries only a fenced retired Redis listener off the Bukkit publication callback. */ + private void closeRetiredRedisAsync(RedisBackendProxyTransport retired) { + Thread cleanup = new Thread(() -> { + RuntimeException cleanupFailure = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + retired.close(); + return; + } catch (RuntimeException failure) { + cleanupFailure = failure; + if (attempt == 2) break; + try { + Thread.sleep(250L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + break; + } + } + } + if (cleanupFailure != null && plugin != null) { + plugin.getLogger().warning("Retired Redis backend listener did not stop cleanly during async cleanup"); + plugin.debug(cleanupFailure); + } + }, "VotingPlugin-Retired-Redis-Cleanup"); + cleanup.setDaemon(true); + cleanup.start(); } public void validate() { + validate(System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(25)); + } + + public void validate(long deadlineNanos) { if (transport == null) throw new IllegalStateException("Backend proxy transport was not initialized"); - transport.validate(); + if (transport instanceof HttpBackendProxyTransport http) http.validate(deadlineNanos); + else transport.validate(); } public void prepareForReplacement() { - if (transport != null) { - transport.prepareForReplacement(); + BackendProxyTransport candidate; + synchronized (this) { + if (transport == null) return; + candidate = transport; + preparedTransport = candidate; transport = null; } + try { + // HTTP preparation can wait on a bounded network flush. Keep send() free to + // enqueue presence and vote messages while that I/O is in progress. + candidate.prepareForReplacement(); + } catch (RuntimeException failure) { + synchronized (this) { + if (preparedTransport != candidate) throw failure; + HttpBackendProxyTransport http = candidate instanceof HttpBackendProxyTransport prepared + ? prepared : null; + MqttBackendProxyTransport mqtt = candidate instanceof MqttBackendProxyTransport prepared + ? prepared : null; + if ((http != null && !http.isClosedForReplacement()) || (mqtt != null && mqtt.isConnected())) { + // A failed flush deliberately restarts the existing connector. Reinstall + // that live instance instead of creating a second directory owner/client. + transport = candidate; + preparedTransport = null; + while (!preparedSends.isEmpty() && transport.send(preparedSends.peekFirst())) + preparedSends.removeFirst(); + preparedQueueWarning = false; + } else { + // Enrollment cancellation may already have closed this instance. Restore + // from its captured configuration before configuration rollback. + try { + restorePreparedTransport(); + } catch (RuntimeException restorationFailure) { + failure.addSuppressed(restorationFailure); + } + } + } + throw failure; + } } - public void closeRedisForHandoff() { - if (!(transport instanceof RedisBackendProxyTransport)) { - throw new IllegalStateException("Redis backend proxy transport is unavailable"); + public synchronized void completePreparedTransportHandoff(BackendProxyTransportManager replacement) { + if (preparedTransport == null && !preparedSendFence) return; + BackendProxyTransportManager target = java.util.Objects.requireNonNull(replacement, "replacement"); + java.util.ArrayList pending = new java.util.ArrayList<>(); + if (preparedTransport instanceof HttpBackendProxyTransport http) { + pending.addAll(http.preparedMessagesSnapshot()); } - ((RedisBackendProxyTransport) transport).closeForHandoff(); - transport = null; + pending.addAll(preparedSends); + target.acceptPreparedHandoffMessages(pending); + // Do not consume the old queues or forward subsequent sends until the + // replacement has admitted every snapshot. A failed admission therefore + // leaves rollback with the complete original FIFO intact. + if (preparedTransport instanceof HttpBackendProxyTransport http) http.drainPreparedMessages(); + preparedSends.clear(); + forwardingManager = target; + preparedSendFence = false; + } + + /** Prevents disabling from discarding a delivery accepted during HTTP preparation. */ + public synchronized boolean commitPreparedDisable() { + if (preparedTransport instanceof HttpBackendProxyTransport http && http.preparedMessageCount() != 0) return false; + if (!preparedSends.isEmpty()) return false; + rejectPreparedSends = true; + return true; + } + + /** Fences ordinary sends while allowing one final stopped-presence envelope. */ + public synchronized void beginPreparedDisable() { + rejectPreparedSends = true; + allowStoppedPresenceDuringDisable = true; + } + + /** Reopens delivery when a prepared disable is rolled back. */ + public synchronized void cancelPreparedDisable() { + rejectPreparedSends = false; + allowStoppedPresenceDuringDisable = false; + rejectedSendWarning = false; + } + + public synchronized boolean hasPendingAsyncHandoff() { + return !asyncHandoffSends.isEmpty() || asyncHandoffWorker != null || pluginMessageHandoffScheduled; + } + + /** Drains an older handoff, then atomically buffers sends until publication. */ + public synchronized void prepareAsyncHandoffForReplacement(long deadlineNanos) { + awaitAsyncHandoff(deadlineNanos); + preparedSendFence = true; + } + + /** Waits off-thread so a later replacement cannot clear an unfinished admitted handoff. */ + public synchronized void awaitAsyncHandoff(long deadlineNanos) { + while (hasPendingAsyncHandoff()) { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0) + throw new IllegalStateException("Timed out waiting for pending backend proxy deliveries"); + try { + java.util.concurrent.TimeUnit.NANOSECONDS.timedWait(this, remaining); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Interrupted while waiting for pending backend proxy deliveries", interrupted); + } + } + } + + /** Holds staged replacement sends until its predecessor FIFO can be prepended. */ + public synchronized void beginPreparedTransportHandoff() { + if (transport instanceof HttpBackendProxyTransport http) { + http.beginPreparedHandoff(); + } else { + preparedSendFence = true; + } + } + + /** Admits the predecessor before staged replacement messages without caller-thread I/O. */ + private void acceptPreparedHandoffMessages(java.util.List pending) { + if (transport instanceof HttpBackendProxyTransport http) { + http.acceptHandoffMessages(pending); + return; + } + Thread worker = null; + boolean schedulePluginMessages = false; + long generation = 0; + synchronized (this) { + if (!preparedSendFence) + throw new IllegalStateException("Replacement transport is not awaiting a prepared handoff"); + int admitted = pending.size() + preparedSends.size(); + if (admitted > MAX_ASYNC_HANDOFF_SENDS - asyncHandoffSends.size()) + throw new IllegalStateException("Backend proxy handoff queue exceeded its fixed capacity"); + asyncHandoffSends.addAll(pending); + asyncHandoffSends.addAll(preparedSends); + preparedSends.clear(); + preparedSendFence = false; + if (!asyncHandoffSends.isEmpty() && asyncHandoffWorker == null && !pluginMessageHandoffScheduled) { + if (transport instanceof PluginMessagingBackendProxyTransport) { + pluginMessageHandoffScheduled = true; + schedulePluginMessages = true; + generation = handoffGeneration; + } else { + worker = createAsyncHandoffWorker(); + } + } + } + if (schedulePluginMessages) schedulePluginMessageHandoff(generation); + else startAsyncHandoffWorker(worker); + } + + /** Preserves handoff FIFO and keeps plugin-message API access on the primary thread. */ + private void acceptQueuedTransportSend(JsonEnvelope envelope) { + if (asyncHandoffSends.size() >= MAX_ASYNC_HANDOFF_SENDS) { + if (!preparedQueueWarning) { + preparedQueueWarning = true; + plugin.getLogger().severe("Plugin-message delivery queue is full; delivery was not accepted"); + } + return; + } + asyncHandoffSends.addLast(envelope); + if (transport instanceof PluginMessagingBackendProxyTransport) { + if (pluginMessageHandoffScheduled) return; + pluginMessageHandoffScheduled = true; + long generation = handoffGeneration; + try { + schedulePluginMessageHandoff(generation); + } catch (RuntimeException failure) { + pluginMessageHandoffScheduled = false; + notifyAll(); + throw failure; + } + } else if (asyncHandoffWorker == null) { + startAsyncHandoffWorker(createAsyncHandoffWorker()); + } + } + + private Thread createAsyncHandoffWorker() { + Thread worker = new Thread(this::drainAsyncHandoffMessages, + "VotingPlugin-Backend-Transport-Handoff"); + worker.setDaemon(true); + asyncHandoffWorker = worker; + return worker; + } + + private void startAsyncHandoffWorker(Thread worker) { + if (worker == null) return; + try { + worker.start(); + } catch (RuntimeException failure) { + synchronized (this) { + if (asyncHandoffWorker == worker) asyncHandoffWorker = null; + notifyAll(); + } + throw failure; + } + } + + private void drainAsyncHandoffMessages() { + try { + while (!Thread.currentThread().isInterrupted()) { + JsonEnvelope envelope; + BackendProxyTransport target; + synchronized (this) { + envelope = asyncHandoffSends.peekFirst(); + if (envelope == null) { + if (asyncHandoffWorker == Thread.currentThread()) asyncHandoffWorker = null; + notifyAll(); + return; + } + target = transport; + } + if (target == null) return; + boolean accepted; + try { + accepted = target.send(envelope); + } catch (RuntimeException sendFailure) { + accepted = false; + } + synchronized (this) { + if (!accepted) { + if (!asyncHandoffRetryWarning && plugin != null && plugin.getLogger() != null) { + asyncHandoffRetryWarning = true; + plugin.getLogger().warning( + "Backend proxy handoff delivery was rejected; retaining it for retry"); + } + try { + wait(250L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + continue; + } + asyncHandoffRetryWarning = false; + if (asyncHandoffSends.peekFirst() == envelope) asyncHandoffSends.removeFirst(); + notifyAll(); + } + } + } finally { + synchronized (this) { + if (asyncHandoffWorker == Thread.currentThread()) asyncHandoffWorker = null; + notifyAll(); + } + } + } + + private void schedulePluginMessageHandoff(long generation) { + try { + plugin.getBukkitScheduler().runTask(plugin, () -> drainPluginMessageHandoff(generation)); + } catch (RuntimeException failure) { + synchronized (this) { + if (handoffGeneration == generation) pluginMessageHandoffScheduled = false; + notifyAll(); + } + throw failure; + } + } + + private void drainPluginMessageHandoff(long generation) { + for (int sent = 0; sent < PLUGIN_MESSAGE_HANDOFF_BATCH_SIZE; sent++) { + JsonEnvelope envelope; + BackendProxyTransport target; + synchronized (this) { + if (handoffGeneration != generation) return; + envelope = asyncHandoffSends.peekFirst(); + if (envelope == null) { + pluginMessageHandoffScheduled = false; + preparedQueueWarning = false; + notifyAll(); + return; + } + target = transport; + } + if (!(target instanceof PluginMessagingBackendProxyTransport)) { + synchronized (this) { + if (handoffGeneration == generation) pluginMessageHandoffScheduled = false; + notifyAll(); + } + return; + } + try { + if (!target.send(envelope)) { + schedulePluginMessageHandoff(generation); + return; + } + } catch (RuntimeException failure) { + synchronized (this) { + if (handoffGeneration == generation) pluginMessageHandoffScheduled = false; + notifyAll(); + } + throw failure; + } + synchronized (this) { + if (handoffGeneration != generation) return; + if (asyncHandoffSends.peekFirst() == envelope) asyncHandoffSends.removeFirst(); + notifyAll(); + } + } + synchronized (this) { + if (handoffGeneration != generation) return; + if (asyncHandoffSends.isEmpty()) { + pluginMessageHandoffScheduled = false; + preparedQueueWarning = false; + notifyAll(); + return; + } + } + schedulePluginMessageHandoff(generation); + } + + /** Reserves replacement capacity for every old queued message and future prepared send. */ + public synchronized void reservePreparedTransportHandoff(BackendProxyTransportManager replacement) { + if (preparedTransport == null && !preparedSendFence) return; + if (!(java.util.Objects.requireNonNull(replacement, "replacement").transport + instanceof HttpBackendProxyTransport target)) + throw new IllegalStateException("HTTP replacement transport is unavailable"); + // send() remains available while the previous credential is fenced. Reserve + // its whole remaining bounded allowance, not only the current queue size. + int previousMessages = preparedTransport instanceof HttpBackendProxyTransport previous + ? previous.preparedMessageCount() : 0; + target.reservePreparedHandoffCapacity(previousMessages + MAX_PREPARED_SENDS); + } + + public void beginPreparedHttpHandoff() { + beginPreparedTransportHandoff(); + } + + public synchronized void restorePreparedTransport() { + if (preparedTransport == null) { + if (!preparedSendFence) return; + acceptPreparedHandoffMessages(java.util.Collections.emptyList()); + preparedQueueWarning = false; + return; + } + if (transport != null) return; + if (preparedTransport instanceof HttpBackendProxyTransport http) { + transport = http.recreatePrepared(); + } else if (preparedTransport instanceof SocketBackendProxyTransport socket) { + socket.restoreAfterFailedReplacement(); + transport = socket; + } else if (preparedTransport instanceof MqttBackendProxyTransport mqtt) { + mqtt.restoreAfterFailedReplacement(); + transport = mqtt; + } else { + throw new IllegalStateException("Prepared backend proxy transport cannot be restored"); + } + preparedTransport = null; + preparedSendFence = false; + while (!preparedSends.isEmpty() && transport.send(preparedSends.peekFirst())) preparedSends.removeFirst(); + preparedQueueWarning = false; + } + + public void restoreAfterFailedReplacement() { + restoreAfterFailedReplacement(null); + } + + /** Restores an old Redis listener together with a promoted replacement's unplayed replay FIFO. */ + public void restoreAfterFailedReplacement(BackendProxyTransportManager failedReplacement) { + java.util.List replacementReplay = failedReplacement == null + ? java.util.Collections.emptyList() : failedReplacement.detachRedisReplayForFailedHandoff(); + restoreRetiredRedisAfterFailedHandoff(replacementReplay); + restoreCompletedRedisAfterFailedHandoff(replacementReplay); + if (transport instanceof PluginMessagingBackendProxyTransport pluginMessaging) { + pluginMessaging.restoreAfterFailedReplacement(); + } + restorePreparedTransport(); + } + + private synchronized java.util.List detachRedisReplayForFailedHandoff() { + if (!(transport instanceof RedisBackendProxyTransport redis)) return java.util.Collections.emptyList(); + return redis.detachReplayForFailedHandoff(); + } + + public void awaitPreparedTransportRestoration(long deadlineNanos) { + if (transport instanceof HttpBackendProxyTransport http) http.awaitCredentialRestoration(deadlineNanos); + } + + /** Requires an off-thread drain before abandoning active Redis replay for another transport. */ + public synchronized boolean hasPendingRedisReplay() { + return transport instanceof RedisBackendProxyTransport redis && redis.hasPendingReplayForReplacement(); + } + + public boolean prepareRedisReplayTransition(BungeeMethod replacementMethod, long deadlineNanos) { + RedisBackendProxyTransport redis; + synchronized (this) { + if (replacementMethod == BungeeMethod.REDIS || !(transport instanceof RedisBackendProxyTransport)) return true; + redis = (RedisBackendProxyTransport) transport; + } + if (!redis.awaitReplayDrainForNonRedisReplacement(deadlineNanos)) return false; + synchronized (this) { + if (transport != redis) return false; + preparedSendFence = true; + } + return true; + } + + public java.util.List closeRedisForHandoff(BackendProxyTransportManager replacement) { + RedisBackendProxyTransport retiring; + synchronized (this) { + if (!(transport instanceof RedisBackendProxyTransport)) { + throw new IllegalStateException("Redis backend proxy transport is unavailable"); + } + retiring = (RedisBackendProxyTransport) transport; + retiredTransport = retiring; + // The Redis listener is fenced off-thread before Bukkit publishes the + // staged replacement. Keep sends accepted in that interval in the same + // bounded predecessor FIFO instead of dropping them once transport is + // detached below. Publication transfers this queue ahead of messages the + // staged replacement accepted, while rollback drains it through the + // restored Redis transport. + preparedSendFence = true; + } + java.util.List replay = retiring.freezeReplayForSuccessiveHandoff(); + try { + replacement.acceptRedisReplayFromPreviousHandoff(replay); + } catch (RuntimeException admissionFailure) { + retiring.restoreFrozenReplayAfterFailedSuccessiveHandoff(replay); + synchronized (this) { + if (retiredTransport == retiring) retiredTransport = null; + } + throw admissionFailure; + } + try { + // closeForHandoff() waits for already-running Redis callbacks. Those callbacks + // may publish a reply through send(), so never retain this manager's monitor + // while waiting for them to drain. + retiring.closeForHandoff(); + } catch (RuntimeException failure) { + synchronized (this) { + if (failure instanceof RedisBackendProxyTransport.HandoffQuiescenceException + && retiredTransport == retiring && transport == retiring) { + replacement.removeRedisReplayFromPreviousHandoff(replay); + retiring.restoreFrozenReplayAfterFailedSuccessiveHandoff(replay); + retiredTransport = null; + } else if (transport == retiring) { + transport = null; + } + } + if (failure instanceof RedisBackendProxyTransport.HandoffQuiescenceException) throw failure; + // Retain the fenced old listener so a later manager close can retry its + // cleanup without ever touching the promoted replacement. + if (plugin != null) { + plugin.getLogger().warning("Previous Redis backend listener did not stop cleanly after handoff"); + plugin.debug(failure); + } + } + synchronized (this) { + if (transport == retiring) transport = null; + } + return replay; + } + + private void acceptRedisReplayFromPreviousHandoff(java.util.List replay) { + if (transport instanceof RedisBackendProxyTransport redis) redis.acceptReplayFromPreviousHandoff(replay); + else if (!replay.isEmpty()) throw new IllegalStateException("Redis replacement transport is unavailable"); + } + + private void removeRedisReplayFromPreviousHandoff(java.util.List replay) { + if (transport instanceof RedisBackendProxyTransport redis) redis.removeReplayFromPreviousHandoff(replay); } public void activateRedisAfterHandoff() { @@ -93,6 +667,90 @@ public void activateRedisAfterHandoff() { ((RedisBackendProxyTransport) transport).activateAfterHandoff(); } + public void replayRedisAfterHandoffPublication() { + if (transport instanceof RedisBackendProxyTransport redis) redis.replayAfterHandoffPublication(); + } + + /** Fences the old Redis listener before promoting the validated standby. */ + public void completeRedisHandoff(BackendProxyTransportManager replacement) { + java.util.Objects.requireNonNull(replacement, "replacement"); + java.util.List replay = closeRedisForHandoff(replacement); + try { + replacement.activateRedisAfterHandoff(); + } catch (RuntimeException activationFailure) { + java.util.List rollbackReplay = replacement.detachRedisReplayForFailedHandoff(); + restoreRetiredRedisAfterFailedHandoff(activationFailure, + mergeRedisRollbackReplay(replay, rollbackReplay)); + throw activationFailure; + } + closeRetiredRedisAfterHandoff(); + } + + private static java.util.List mergeRedisRollbackReplay(java.util.List predecessorReplay, + java.util.List standbyReplay) { + if (standbyReplay.isEmpty()) return predecessorReplay; + if (predecessorReplay.isEmpty()) return standbyReplay; + if (standbyReplay.size() >= predecessorReplay.size() + && standbyReplay.subList(0, predecessorReplay.size()).equals(predecessorReplay)) return standbyReplay; + java.util.ArrayList merged = new java.util.ArrayList<>( + predecessorReplay.size() + standbyReplay.size()); + merged.addAll(predecessorReplay); + merged.addAll(standbyReplay); + if (merged.size() > RedisBackendProxyTransport.MAX_REPLAY_HANDOFF_DELIVERIES) + throw new IllegalStateException("Redis rollback replay exceeds its bounded handoff capacity"); + return merged; + } + + private synchronized void restoreRetiredRedisAfterFailedHandoff(RuntimeException activationFailure, + java.util.List replacementReplay) { + try { + restoreRetiredRedisAfterFailedHandoff(replacementReplay); + } catch (RuntimeException restorationFailure) { + activationFailure.addSuppressed(restorationFailure); + } + } + + private synchronized void restoreRetiredRedisAfterFailedHandoff(java.util.List replacementReplay) { + if (transport != null) return; + if (!(retiredTransport instanceof RedisBackendProxyTransport redis)) return; + try { + redis.restoreAfterFailedHandoff(replacementReplay); + transport = redis; + retiredTransport = null; + } catch (RuntimeException restorationFailure) { + throw new IllegalStateException("Retired Redis backend listener could not be restored", restorationFailure); + } + } + + private synchronized void closeRetiredRedisAfterHandoff() { + if (retiredTransport == null) return; + BackendProxyTransport retired = retiredTransport; + retiredTransport = null; + try { + retired.close(); + if (retired instanceof RedisBackendProxyTransport redis) completedRedisHandoffTransport = redis; + } catch (RuntimeException cleanupFailure) { + retiredTransport = retired; + if (plugin != null) { + plugin.getLogger().warning("Retired Redis backend listener did not stop cleanly after handoff"); + plugin.debug(cleanupFailure); + } + } + } + + /** Restores a worker-retired Redis listener when publication did not commit. */ + private synchronized void restoreCompletedRedisAfterFailedHandoff(java.util.List replacementReplay) { + if (transport != null || completedRedisHandoffTransport == null) return; + RedisBackendProxyTransport retired = completedRedisHandoffTransport; + try { + retired.restoreAfterFailedHandoff(replacementReplay); + transport = retired; + completedRedisHandoffTransport = null; + } catch (RuntimeException restorationFailure) { + throw new IllegalStateException("Retired Redis backend listener could not be restored", restorationFailure); + } + } + public ClientHandler getClientHandler() { return transport instanceof SocketBackendProxyTransport ? ((SocketBackendProxyTransport) transport).getClientHandler() : null; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java new file mode 100644 index 000000000..919c96f54 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransport.java @@ -0,0 +1,783 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.time.Clock; +import java.util.ArrayDeque; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicInteger; + +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.simpleapi.servercomm.http.HttpBackendTransportConnector; +import com.bencodez.simpleapi.servercomm.http.HttpClientCredentialStore; +import com.bencodez.simpleapi.servercomm.http.HttpConnectionCode; +import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Backend adapter for the secure outbound-only HTTP proxy transport. */ +public final class HttpBackendProxyTransport implements BackendProxyTransport { + private static final int MAX_STARTUP_QUEUE = 1024; + private static final int MAX_PREPUBLICATION_QUEUE = 2048; + private static final int MAX_HANDOFF_QUEUE = 4096; + private static final long DEFAULT_STARTUP_VALIDATION_SECONDS = 25L; + private static final long ENROLLMENT_RETRY_INITIAL_MILLIS = 1_000L; + private static final long ENROLLMENT_RETRY_MAX_MILLIS = 60_000L; + private static final long INCOMING_DISPATCH_SECONDS = 25L; + private static final long SHUTDOWN_FLUSH_SECONDS = 5L; + private static final ConcurrentHashMap DIRECTORY_OWNERS = new ConcurrentHashMap<>(); + private final VotingPluginMain plugin; + private final Object lifecycle = new Object(); + private final CountDownLatch startupComplete = new CountDownLatch(1); + private final CountDownLatch credentialRestoreComplete = new CountDownLatch(1); + private final ArrayDeque startupQueue = new ArrayDeque<>(); + private final ArrayDeque handoffQueue = new ArrayDeque<>(); + private volatile Thread handoffWorker; + private boolean awaitingPreparedHandoff; + /** Capacity reserved for the prepared predecessor before this transport is published. */ + private int preparedHandoffReservation; + private volatile HttpBackendTransportConnector connector; + private volatile Thread worker; + private volatile RuntimeException startupFailure; + private volatile RuntimeException credentialRestoreFailure; + private volatile boolean started; + private volatile boolean closed; + /** True only after this transport has crossed the handler publication boundary. */ + private boolean published; + private volatile boolean restartAfterFailedFlush; + private final java.util.concurrent.atomic.AtomicBoolean flushRecoveryRunning = new java.util.concurrent.atomic.AtomicBoolean(); + private Path configuredDirectory; + private String configuredServerId; + private String configuredConnectionCode; + private GlobalMessageHandler configuredMessageHandler; + private HttpClientCredentialStore.ActiveCredentialGeneration configuredCredentialGeneration; + private HttpClientCredentialStore.ActiveCredentialGeneration credentialGenerationToRestore; + private boolean retryInitialization; + private boolean restoreUnenrolledState; + private boolean inboundActive; + private Semaphore directoryOwner; + private final java.util.concurrent.atomic.AtomicBoolean queueWarning = new java.util.concurrent.atomic.AtomicBoolean(); + + public HttpBackendProxyTransport(VotingPluginMain plugin) { + this.plugin = plugin; + } + + @Override + public void start(GlobalMessageHandler messageHandler) { + start(messageHandler, true); + } + + @Override + public void start(GlobalMessageHandler messageHandler, boolean retryInitialization) { + Path directory = plugin.getDataFolder().toPath().resolve("http"); + String serverId = plugin.getBungeeSettings().getServer(); + String connectionCode = plugin.getBungeeSettings().getHttpConnectionCode(); + start(directory, serverId, connectionCode, messageHandler, null, retryInitialization, false, + retryInitialization); + } + + private void start(Path directory, String serverId, String connectionCode, + GlobalMessageHandler messageHandler) { + start(directory, serverId, connectionCode, messageHandler, null, true, false, true); + } + + private void start(Path directory, String serverId, String connectionCode, + GlobalMessageHandler messageHandler, + HttpClientCredentialStore.ActiveCredentialGeneration generationToRestore) { + start(directory, serverId, connectionCode, messageHandler, generationToRestore, false, false, true); + } + + private void start(Path directory, String serverId, String connectionCode, + GlobalMessageHandler messageHandler, + HttpClientCredentialStore.ActiveCredentialGeneration generationToRestore, + boolean retryInitialization, boolean restoreUnenrolledState, boolean inboundActive) { + if (generationToRestore == null && !restoreUnenrolledState) + validateConfiguration(directory, serverId, connectionCode); + else HttpTlsIdentity.canonicalServerId(serverId); + configuredDirectory = directory; + configuredServerId = serverId; + configuredConnectionCode = connectionCode; + configuredMessageHandler = messageHandler; + credentialGenerationToRestore = generationToRestore; + this.retryInitialization = retryInitialization; + this.restoreUnenrolledState = restoreUnenrolledState; + this.inboundActive = inboundActive; + this.published = inboundActive; + started = true; + worker = new Thread(() -> initialize(directory, serverId, connectionCode, messageHandler, retryInitialization, + restoreUnenrolledState), + "VotingPlugin-HTTP-Backend-Setup"); + worker.setDaemon(true); + worker.start(); + } + + HttpBackendProxyTransport recreatePrepared() { + HttpBackendProxyTransport restored = new HttpBackendProxyTransport(plugin); + synchronized (lifecycle) { + // Startup and handoff queues are one FIFO from the caller's perspective. + // The handoff queue can still contain messages accepted by the previous + // replacement, so rollback must carry it into the restored transport too. + // Keep the combined queue in the asynchronously drained handoff lane: it can + // be larger than the connector's bounded startup queue. + restored.handoffQueue.addAll(takeQueuedMessages()); + } + restored.start(configuredDirectory, configuredServerId, configuredConnectionCode, configuredMessageHandler, + configuredCredentialGeneration, configuredCredentialGeneration == null && retryInitialization, + restoreUnenrolledState, true); + return restored; + } + + boolean isClosedForReplacement() { + return closed; + } + + java.util.List drainPreparedMessages() { + synchronized (lifecycle) { + return takeQueuedMessages(); + } + } + + int preparedMessageCount() { + synchronized (lifecycle) { + return startupQueue.size() + handoffQueue.size(); + } + } + + java.util.List preparedMessagesSnapshot() { + synchronized (lifecycle) { + java.util.List pending = new java.util.ArrayList<>(startupQueue.size() + handoffQueue.size()); + pending.addAll(startupQueue); + pending.addAll(handoffQueue); + return pending; + } + } + + /** Takes both pending queues in their original FIFO order for replacement handoff. */ + private java.util.List takeQueuedMessages() { + java.util.List pending = new java.util.ArrayList<>(startupQueue.size() + handoffQueue.size()); + pending.addAll(startupQueue); + pending.addAll(handoffQueue); + startupQueue.clear(); + handoffQueue.clear(); + return pending; + } + + @Override + public void prepareForReplacement() { + HttpBackendTransportConnector active; + Thread cancelledInitialization = null; + /* + * Keep the check, credential snapshot, and cancellation in one lifecycle + * critical section. During ordinary first-time enrollment the setup worker + * owns DIRECTORY_OWNERS while it retries and connector is intentionally null. + * A Control replacement must be able to cancel that worker before claiming + * the same credential directory; there is no credential to preserve in that + * state. If enrollment has already published a credential but the connector + * has not yet been installed, retain the generation just as we do for an + * established connector. + */ + synchronized (lifecycle) { + if (configuredDirectory == null) + throw new IllegalStateException("Could not preserve the active HTTP client credential before it became ready"); + active = connector; + if (active == null && !HttpClientCredentialStore.hasEnrolledProfile(configuredDirectory)) { + restoreUnenrolledState = true; + cancelledInitialization = worker; + closeForReplacement(); + } else try { + configuredCredentialGeneration = HttpClientCredentialStore.snapshotActiveGeneration(configuredDirectory); + } catch (Exception failure) { + throw new IllegalStateException("Could not preserve the active HTTP client credential", failure); + } + if (cancelledInitialization == null && active == null) { + closeForReplacement(); + return; + } + } + if (cancelledInitialization != null) { + awaitCancelledInitialization(cancelledInitialization); + captureEnrollmentPublishedDuringCancellation(); + return; + } + flushForReplacement(active, System.nanoTime() + TimeUnit.SECONDS.toNanos(SHUTDOWN_FLUSH_SECONDS)); + closeForReplacement(); + } + + private void awaitCancelledInitialization(Thread setup) { + try { setup.join(TimeUnit.SECONDS.toMillis(SHUTDOWN_FLUSH_SECONDS)); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while stopping HTTP client enrollment", interrupted); + } + if (setup.isAlive()) + throw new IllegalStateException("Could not stop HTTP client enrollment before replacement"); + } + + private void captureEnrollmentPublishedDuringCancellation() { + if (!HttpClientCredentialStore.hasEnrolledProfile(configuredDirectory)) return; + try { + HttpConnectionCode original = HttpConnectionCode.parse(configuredConnectionCode); + if (!original.serverId().equals(HttpTlsIdentity.canonicalServerId(configuredServerId)) + || !HttpClientCredentialStore.matchesEnrollmentCode(configuredDirectory, original)) + throw new IllegalStateException("HTTP client enrollment changed during replacement preparation"); + configuredCredentialGeneration = HttpClientCredentialStore.snapshotActiveGeneration(configuredDirectory); + restoreUnenrolledState = false; + } catch (IllegalStateException failure) { throw failure; } + catch (Exception failure) { + throw new IllegalStateException("Could not preserve the completed HTTP client enrollment", failure); + } + } + + void flushForReplacement(HttpBackendTransportConnector connector, long deadlineNanos) { + if (connector.flushOutgoing(deadlineNanos)) return; + restartAfterFailedFlush = true; + connector.start(); + if (flushRecoveryRunning.compareAndSet(false, true)) { + Thread recovery = new Thread(() -> resumeAfterFailedFlush(connector), + "VotingPlugin-HTTP-Backend-Flush-Recovery"); + recovery.setDaemon(true); + recovery.start(); + } + throw new IllegalStateException("Could not drain the active HTTP transport before replacement"); + } + + private void resumeAfterFailedFlush(HttpBackendTransportConnector active) { + try { + // start() intentionally does nothing while the interrupted long-poll worker is + // still winding down. Keep a single recovery owner alive until this transport is + // closed or replaced so the connector's already-accepted queue cannot be stranded. + while (!closed && connector == active) { + active.start(); + synchronized (lifecycle) { + while (!startupQueue.isEmpty() && active.send(startupQueue.peekFirst())) startupQueue.removeFirst(); + } + try { TimeUnit.SECONDS.sleep(1); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); break; } + } + } finally { + flushRecoveryRunning.set(false); + if (closed || connector != active) restartAfterFailedFlush = false; + } + } + + private void initialize(Path directory, String serverId, String configuredCode, + GlobalMessageHandler messageHandler, boolean retryEnrollment, boolean restoreUnenrolledState) { + Path ownerKey = directory.toAbsolutePath().normalize(); + Semaphore owner = DIRECTORY_OWNERS.computeIfAbsent(ownerKey, ignored -> new Semaphore(1)); + boolean acquired = false, installed = false; + HttpBackendTransportConnector replacement = null; + try { + owner.acquire(); + acquired = true; + synchronized (lifecycle) { + if (closed) { + if (credentialGenerationToRestore != null || restoreUnenrolledState) + credentialRestoreFailure = new IllegalStateException( + "Previous HTTP client credential state restoration was cancelled"); + return; + } + } + try { + if (restoreUnenrolledState) + restoreUnenrolledCredentialState(directory, serverId, configuredCode); + if (credentialGenerationToRestore != null) { + HttpClientCredentialStore.restoreActiveGenerationAfterReplacement(directory, + credentialGenerationToRestore); + } + } catch (Exception failure) { + credentialRestoreFailure = new IllegalStateException( + "Could not restore the previous HTTP client credential state", failure); + throw failure; + } + credentialRestoreComplete.countDown(); + // An enrolled rollback resumes the validated generation without replaying its + // temporary code. An un-enrolled rollback reuses the original startup code + // only after the staged credential has been made inactive. + HttpConnectionCode code = enrollmentCode(directory, serverId, + credentialGenerationToRestore == null ? configuredCode : null); + if (code != null && !enrollForStartup(code, serverId, directory, retryEnrollment, + this::waitForEnrollmentRetry)) return; + HttpClientCredentialStore.EnrolledClient enrolled = HttpClientCredentialStore.loadEnrolled(directory); + if (!enrolled.profile().serverId().equals(HttpTlsIdentity.canonicalServerId(serverId))) + throw new IllegalStateException("Persisted HTTP identity belongs to a different backend Server name"); + replacement = new HttpBackendTransportConnector(directory, envelope -> { + dispatchAfterPublication(messageHandler, envelope); + }); + invokeConnectorLifecycle(replacement, "startPaused"); + boolean discard = false; + synchronized (lifecycle) { + if (closed) { + discard = true; + } else { + transferStartupQueue(startupQueue, replacement); + connector = replacement; + directoryOwner = owner; + installed = true; + if (inboundActive) invokeConnectorLifecycle(replacement, "activateIncoming"); + } + } + if (!discard) startHandoffDrainIfNeeded(); + } catch (Exception failure) { + startupFailure = new IllegalStateException("Secure HTTP backend enrollment or connection failed", failure); + plugin.getLogger().severe("Secure HTTP backend transport is unavailable; check the connection code and proxy endpoint"); + } finally { + credentialRestoreComplete.countDown(); + finishInitialization(installed, replacement, acquired, owner, startupComplete); + } + } + + static void finishInitialization(boolean installed, HttpBackendTransportConnector replacement, + boolean acquired, Semaphore owner, CountDownLatch completion) { + try { + if (!installed) { + try { + if (replacement != null) replacement.close(); + } finally { + if (acquired) owner.release(); + } + } + } finally { + completion.countDown(); + } + } + + static void transferStartupQueue(ArrayDeque queue, HttpBackendTransportConnector connector) { + while (!queue.isEmpty()) { + JsonEnvelope envelope = queue.peekFirst(); + if (!connector.send(envelope)) { + throw new IllegalStateException("HTTP startup queue could not be transferred"); + } + queue.removeFirst(); + } + } + + @Override + public void activateAfterPublication() { + HttpBackendTransportConnector active; + synchronized (lifecycle) { + if (closed) return; + active = connector; + } + if (active != null) invokeConnectorLifecycle(active, "activateIncoming"); + synchronized (lifecycle) { + if (closed) return; + inboundActive = true; + published = true; + lifecycle.notifyAll(); + } + } + + private boolean awaitInboundPublication() { + synchronized (lifecycle) { + while (!inboundActive && !closed) { + try { + lifecycle.wait(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + return !closed; + } + } + + void dispatchAfterPublication(GlobalMessageHandler messageHandler, JsonEnvelope envelope) { + if (!awaitInboundPublication()) + throw new IllegalStateException("HTTP transport closed before inbound publication"); + dispatchIncoming(messageHandler, envelope, + System.nanoTime() + TimeUnit.SECONDS.toNanos(INCOMING_DISPATCH_SECONDS)); + } + + private static void invokeConnectorLifecycle(HttpBackendTransportConnector connector, String method) { + try { + connector.getClass().getMethod(method).invoke(connector); + } catch (java.lang.reflect.InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof RuntimeException runtime) throw runtime; + if (cause instanceof Error error) throw error; + throw new IllegalStateException("HTTP connector " + method + " failed", cause); + } catch (NoSuchMethodException unavailable) { + // Older published SimpleAPI snapshots do not yet expose the publication + // barrier. The wrapper callback above provides the same fence until #79 is + // deployed, while newer versions use the native connector barrier. + if ("startPaused".equals(method)) connector.start(); + else if (!"activateIncoming".equals(method)) + throw new IllegalStateException("SimpleAPI HTTP connector does not support " + method, unavailable); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("SimpleAPI HTTP connector does not support " + method, failure); + } + } + + static void restoreUnenrolledCredentialState(Path directory, String serverId, String configuredCode) throws Exception { + Path root = directory.toAbsolutePath().normalize(); + if (configuredCode != null && !configuredCode.isBlank() + && HttpClientCredentialStore.hasEnrolledProfile(root)) { + try { + HttpConnectionCode original = HttpConnectionCode.parse(configuredCode); + if (original.serverId().equals(HttpTlsIdentity.canonicalServerId(serverId)) + && HttpClientCredentialStore.matchesEnrollmentCode(root, original)) return; + } catch (Exception ignored) { + // The pre-replacement state was un-enrolled; never retain a credential + // that cannot be tied to its already-validated original code. + } + } + Path current = root.resolve("http-transport-client-current").normalize(); + if (!current.getParent().equals(root) || Files.isSymbolicLink(current)) + throw new java.io.IOException("HTTP client credential pointer is unsafe"); + if (Files.exists(current, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(current, LinkOption.NOFOLLOW_LINKS)) + throw new java.io.IOException("HTTP client credential pointer is unsafe"); + DurableFiles.deleteIfExists(current); + if (HttpClientCredentialStore.hasEnrolledProfile(root)) + throw new java.io.IOException("HTTP client credential rollback did not restore the un-enrolled state"); + } + + private boolean waitForEnrollmentRetry(long delayMillis) { + if (closed) return false; + try { + TimeUnit.MILLISECONDS.sleep(delayMillis); + return !closed; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + + /** Performs initial enrollment with a bounded backoff for ordinary startup. */ + boolean enrollForStartup(HttpConnectionCode code, String serverId, Path directory, + boolean retryEnrollment, java.util.function.LongPredicate waitForRetry) throws Exception { + long retryDelayMillis = ENROLLMENT_RETRY_INITIAL_MILLIS; + while (true) { + if (closed) return false; + try { + HttpBackendTransportConnector.enroll(code, serverId, directory); + return true; + } catch (Exception failure) { + if (!retryEnrollment) throw failure; + plugin.getLogger().warning("Secure HTTP backend enrollment failed; retrying with bounded backoff"); + if (!waitForRetry.test(retryDelayMillis)) return false; + retryDelayMillis = Math.min(ENROLLMENT_RETRY_MAX_MILLIS, retryDelayMillis * 2L); + } + } + } + + void dispatchIncoming(GlobalMessageHandler messageHandler, JsonEnvelope envelope, long deadlineNanos) { + CountDownLatch completed = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + AtomicInteger state = new AtomicInteger(0); // pending, running, cancelled, finished + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (!state.compareAndSet(0, 1)) { + completed.countDown(); + return; + } + try { + messageHandler.onMessage(envelope); + } catch (Throwable thrown) { + failure.set(thrown); + } finally { + state.set(3); + completed.countDown(); + } + }); + } catch (Throwable rejected) { + throw new IllegalStateException("Could not schedule an incoming HTTP message on the server thread", rejected); + } + try { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L || !completed.await(remaining, TimeUnit.NANOSECONDS)) { + state.compareAndSet(0, 2); + throw new IllegalStateException("Incoming HTTP message handling exceeded its delivery deadline"); + } + } catch (InterruptedException interrupted) { + state.compareAndSet(0, 2); + Thread.currentThread().interrupt(); + throw new IllegalStateException("Incoming HTTP message handling was interrupted", interrupted); + } + Throwable thrown = failure.get(); + if (thrown != null) + throw new IllegalStateException("Incoming HTTP message handling failed", thrown); + } + + void awaitCredentialRestoration(long deadlineNanos) { + if (credentialGenerationToRestore == null && !restoreUnenrolledState) return; + try { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L || !credentialRestoreComplete.await(remaining, TimeUnit.NANOSECONDS)) + throw new IllegalStateException("Previous HTTP client credential restoration timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Previous HTTP client credential restoration was interrupted", interrupted); + } + RuntimeException failure = credentialRestoreFailure; + if (failure != null) throw failure; + } + + @Override + public boolean send(JsonEnvelope envelope) { + synchronized (lifecycle) { + if (closed) return false; + if (awaitingPreparedHandoff || !handoffQueue.isEmpty()) { + int capacity = awaitingPreparedHandoff + ? Math.min(MAX_PREPUBLICATION_QUEUE, MAX_HANDOFF_QUEUE - preparedHandoffReservation) + : MAX_HANDOFF_QUEUE; + if (handoffQueue.size() < capacity) { + handoffQueue.addLast(envelope); + return true; + } + warnRejectedSend(); + return false; + } + HttpBackendTransportConnector active = connector; + if (active != null) { + if (!active.send(envelope)) { + if (restartAfterFailedFlush && startupQueue.size() < MAX_STARTUP_QUEUE) { + startupQueue.addLast(envelope); + return true; + } + warnRejectedSend(); + return false; + } + } else if (startupQueue.size() < MAX_STARTUP_QUEUE) { + startupQueue.addLast(envelope); + return true; + } else { + warnRejectedSend(); + return false; + } + return true; + } + } + + void beginPreparedHandoff() { + synchronized (lifecycle) { + if (closed) throw new IllegalStateException("HTTP replacement transport is closed"); + awaitingPreparedHandoff = true; + } + } + + /** + * Reserves enough of the bounded handoff queue for the predecessor before callers + * can send through this staged replacement. This turns an otherwise late, + * destructive capacity failure into a pre-publication validation failure. + */ + void reservePreparedHandoffCapacity(int messages) { + if (messages < 0 || messages > MAX_HANDOFF_QUEUE) + throw new IllegalStateException("HTTP prepared handoff exceeds its fixed capacity"); + synchronized (lifecycle) { + if (closed) throw new IllegalStateException("HTTP replacement transport is closed"); + if (!awaitingPreparedHandoff) + throw new IllegalStateException("HTTP replacement transport is not awaiting a prepared handoff"); + if (handoffQueue.size() > MAX_HANDOFF_QUEUE - messages) + throw new IllegalStateException("HTTP handoff queue exceeded its reserved capacity"); + preparedHandoffReservation = messages; + } + } + + void acceptHandoffMessages(java.util.List messages) { + synchronized (lifecycle) { + if (closed) throw new IllegalStateException("HTTP replacement transport is closed"); + if (messages.size() > MAX_HANDOFF_QUEUE - handoffQueue.size()) + throw new IllegalStateException("HTTP handoff queue exceeded its fixed capacity"); + java.util.ArrayDeque newer = new java.util.ArrayDeque<>(handoffQueue); + handoffQueue.clear(); + handoffQueue.addAll(messages); + handoffQueue.addAll(newer); + awaitingPreparedHandoff = false; + preparedHandoffReservation = 0; + } + startHandoffDrainIfNeeded(); + } + + private void startHandoffDrainIfNeeded() { + Thread drain; + synchronized (lifecycle) { + if (closed || handoffQueue.isEmpty() || handoffWorker != null || connector == null) return; + drain = new Thread(this::drainHandoffMessages, "VotingPlugin-HTTP-Backend-Handoff"); + drain.setDaemon(true); + handoffWorker = drain; + } + drain.start(); + } + + private void drainHandoffMessages() { + try { + while (!Thread.currentThread().isInterrupted()) { + boolean delivered = false; + synchronized (lifecycle) { + if (closed || handoffQueue.isEmpty()) return; + HttpBackendTransportConnector active = connector; + if (active != null && active.send(handoffQueue.peekFirst())) { + handoffQueue.removeFirst(); + delivered = true; + } + } + if (!delivered) TimeUnit.MILLISECONDS.sleep(25L); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + synchronized (lifecycle) { + if (handoffWorker == Thread.currentThread()) handoffWorker = null; + } + } + } + + java.util.List handoffMessagesSnapshot() { + synchronized (lifecycle) { + return java.util.List.copyOf(handoffQueue); + } + } + + private void warnRejectedSend() { + if (queueWarning.compareAndSet(false, true)) + plugin.getLogger().severe("Secure HTTP transport queue is full or rejected an oversized message; delivery was not accepted"); + } + + @Override + public void validate() { + validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(DEFAULT_STARTUP_VALIDATION_SECONDS)); + } + + void validate(long deadlineNanos) { + String serverId = plugin.getBungeeSettings().getServer(); + if (serverId == null || !serverId.matches("[A-Za-z0-9][A-Za-z0-9._-]{0,63}")) { + throw new IllegalStateException("HTTP requires a valid unique backend Server name"); + } + Path directory = plugin.getDataFolder().toPath().resolve("http"); + validateConfiguration(directory, serverId, plugin.getBungeeSettings().getHttpConnectionCode()); + if (!started) return; + try { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L || !startupComplete.await(remaining, TimeUnit.NANOSECONDS)) + throw new IllegalStateException("Secure HTTP backend setup did not finish within the validation deadline"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Secure HTTP backend setup validation was interrupted", interrupted); + } + RuntimeException failure = startupFailure; + if (failure != null) throw failure; + HttpBackendTransportConnector active = connector; + if (closed || active == null) throw new IllegalStateException("Secure HTTP backend transport did not initialize"); + try { + if (!active.awaitFirstResponse(deadlineNanos)) + throw new IllegalStateException("HTTP backend could not authenticate with the proxy"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Secure HTTP backend readiness validation was interrupted", interrupted); + } + } + + public static void validateConfiguration(Path directory, String serverId, String configuredCode) { + enrollmentCode(directory, serverId, configuredCode); + } + + static HttpConnectionCode enrollmentCode(Path directory, String serverId, String configuredCode) { + try { serverId = HttpTlsIdentity.canonicalServerId(serverId); } + catch (IllegalArgumentException invalid) { throw new IllegalStateException("HTTP requires a valid unique backend Server name", invalid); } + boolean enrolled = HttpClientCredentialStore.hasEnrolledProfile(directory); + if (configuredCode != null && !configuredCode.isBlank()) { + try { + HttpConnectionCode code = HttpConnectionCode.parse(configuredCode); + if (!code.serverId().equals(serverId)) + throw new IllegalArgumentException("Connection code belongs to a different backend"); + if (enrolled && HttpClientCredentialStore.matchesEnrollmentCode(directory, code)) return null; + code.requireActive(Clock.systemUTC()); + return code; + } catch (Exception invalid) { + throw new IllegalStateException("HTTP ConnectionCode is invalid, expired, or belongs to a different backend", invalid); + } + } + if (!enrolled) + throw new IllegalStateException("HTTP requires a temporary ConnectionCode for initial enrollment"); + return null; + } + + @Override + public void close() { + close(true); + } + + private void closeForReplacement() { + close(false); + } + + private void close(boolean discardQueuedMessages) { + Thread setup; + Thread pendingHandoff; + HttpBackendTransportConnector active; + Semaphore owner; + java.util.List finalHandoff; + synchronized (lifecycle) { + if (closed) return; + closed = true; + lifecycle.notifyAll(); + if (discardQueuedMessages) { + if (published) { + finalHandoff = new java.util.ArrayList<>(startupQueue.size() + handoffQueue.size()); + finalHandoff.addAll(startupQueue); + finalHandoff.addAll(handoffQueue); + } else { + // A staged replacement never became authoritative. Its presence and + // handoff messages must not be flushed after rollback. + finalHandoff = java.util.List.of(); + } + startupQueue.clear(); + handoffQueue.clear(); + } else finalHandoff = java.util.List.of(); + awaitingPreparedHandoff = false; + setup = worker; + worker = null; + pendingHandoff = handoffWorker; + handoffWorker = null; + active = connector; + connector = null; + owner = directoryOwner; + directoryOwner = null; + } + startupComplete.countDown(); + if (setup != null) setup.interrupt(); + if (pendingHandoff != null) pendingHandoff.interrupt(); + if (setup == null && active == null && owner == null) return; + Thread cleanup = new Thread(() -> drain(setup, active, owner, finalHandoff), + "VotingPlugin-HTTP-Backend-Cleanup"); + cleanup.setDaemon(true); + cleanup.start(); + } + + private static void drain(Thread setup, HttpBackendTransportConnector active, Semaphore owner, + java.util.List finalHandoff) { + try { + if (setup != null) try { setup.join(TimeUnit.SECONDS.toMillis(5)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + if (active != null) { + flushHandoffForShutdown(active, finalHandoff, + System.nanoTime() + TimeUnit.SECONDS.toNanos(SHUTDOWN_FLUSH_SECONDS)); + active.close(); + } + } finally { if (owner != null) owner.release(); } + } + + static boolean flushHandoffForShutdown(HttpBackendTransportConnector active, + java.util.List finalHandoff, long deadlineNanos) { + for (JsonEnvelope envelope : finalHandoff) { + while (!active.send(envelope)) { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) return false; + try { + TimeUnit.NANOSECONDS.sleep(Math.min(remaining, TimeUnit.MILLISECONDS.toNanos(25L))); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + } + return active.flushOutgoing(deadlineNanos); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java index 5b9bb44a0..85e2d1253 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MqttBackendProxyTransport.java @@ -15,6 +15,13 @@ public class MqttBackendProxyTransport implements BackendProxyTransport { private final VotingPluginMain plugin; @Getter private MqttHandler mqttHandler; + private GlobalMessageHandler messageHandler; + private String publishTopic; + private String subscriptionTopic; + private String clientId; + private String brokerUrl; + private String username; + private String password; public MqttBackendProxyTransport(VotingPluginMain plugin) { this.plugin = plugin; @@ -23,14 +30,18 @@ public MqttBackendProxyTransport(VotingPluginMain plugin) { @Override public void start(GlobalMessageHandler messageHandler) { try { - String id = plugin.getBungeeSettings().getMqttClientID(); - if (id.isEmpty()) { - id = plugin.getOptions().getServer(); + this.messageHandler = messageHandler; + publishTopic = plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/proxy"; + subscriptionTopic = plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/" + + plugin.getOptions().getServer(); + clientId = plugin.getBungeeSettings().getMqttClientID(); + if (clientId.isEmpty()) { + clientId = plugin.getOptions().getServer(); } - mqttHandler = new MqttHandler(new MqttServerComm(id, plugin.getBungeeSettings().getMqttBrokerURL(), - plugin.getBungeeSettings().getMqttUsername(), plugin.getBungeeSettings().getMqttPassword()), 2); - mqttHandler.subscribeEnvelopes(plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/" - + plugin.getOptions().getServer(), (topic, envelope) -> messageHandler.onMessage(envelope)); + brokerUrl = plugin.getBungeeSettings().getMqttBrokerURL(); + username = plugin.getBungeeSettings().getMqttUsername(); + password = plugin.getBungeeSettings().getMqttPassword(); + startCapturedConnection(); } catch (MqttException e) { throw new IllegalStateException("MQTT backend proxy transport initialization failed", e); } catch (Exception e) { @@ -38,21 +49,53 @@ public void start(GlobalMessageHandler messageHandler) { } } + protected MqttHandler createMqttHandler(MqttServerComm server) throws MqttException { + return new MqttHandler(server, 2); + } + + protected MqttServerComm createMqttServerComm() throws MqttException { + return new MqttServerComm(clientId, brokerUrl, username, password); + } + + private void startCapturedConnection() throws Exception { + MqttHandler candidate = createMqttHandler(createMqttServerComm()); + try { + candidate.subscribeEnvelopes(subscriptionTopic, (topic, envelope) -> messageHandler.onMessage(envelope)); + mqttHandler = candidate; + } catch (Exception subscriptionFailure) { + try { + candidate.disconnect(); + } catch (Exception disconnectFailure) { + subscriptionFailure.addSuppressed(disconnectFailure); + } + throw subscriptionFailure; + } + } + @Override public void validate() { if (mqttHandler == null) throw new IllegalStateException("MQTT backend proxy transport initialization failed"); } + /** Returns whether a failed disconnect left the existing broker session usable. */ + public boolean isConnected() { + return mqttHandler != null && mqttHandler.isConnected(); + } + @Override - public void send(JsonEnvelope envelope) { + public boolean send(JsonEnvelope envelope) { if (mqttHandler == null) { - return; + return false; } try { - mqttHandler.publishEnvelope(plugin.getBungeeSettings().getMqttPrefix() + "votingplugin/servers/proxy", - envelope); + mqttHandler.publishEnvelope(publishTopic, envelope); + return true; } catch (Exception e) { - e.printStackTrace(); + if (plugin != null && plugin.getLogger() != null) { + plugin.getLogger().warning("MQTT backend proxy delivery failed"); + plugin.debug(e); + } + return false; } } @@ -80,4 +123,16 @@ public void close() { } } } + + /** Reconnects a prepared predecessor with its original identity after rollback. */ + public void restoreAfterFailedReplacement() { + if (messageHandler == null || clientId == null || brokerUrl == null || subscriptionTopic == null) { + throw new IllegalStateException("MQTT backend proxy transport cannot be restored before startup"); + } + try { + startCapturedConnection(); + } catch (Exception e) { + throw new IllegalStateException("MQTT backend proxy transport restoration failed", e); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java index dc61e9f70..0cbf34be2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/MysqlBackendProxyTransport.java @@ -43,14 +43,19 @@ public void validate() { } @Override - public void send(JsonEnvelope envelope) { + public boolean send(JsonEnvelope envelope) { if (messenger == null) { - return; + return false; } try { messenger.sendToProxy(envelope); + return true; } catch (SQLException e) { - e.printStackTrace(); + if (plugin != null && plugin.getLogger() != null) { + plugin.getLogger().warning("MySQL backend proxy delivery failed"); + plugin.debug(e); + } + return false; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java index d6a24ff5c..5979e0baf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/PluginMessagingBackendProxyTransport.java @@ -11,6 +11,10 @@ public class PluginMessagingBackendProxyTransport implements BackendProxyTranspo private final VotingPluginMain plugin; private GlobalMessageHandler messageHandler; + private String channel; + private EncryptionHandler encryptionHandler; + private boolean debug; + private boolean active; public PluginMessagingBackendProxyTransport(VotingPluginMain plugin) { this.plugin = plugin; @@ -19,27 +23,53 @@ public PluginMessagingBackendProxyTransport(VotingPluginMain plugin) { @Override public void start(GlobalMessageHandler messageHandler) { this.messageHandler = messageHandler; - plugin.registerBungeeChannels(plugin.getBungeeSettings().getPluginMessagingChannel()); - EncryptionHandler encryptionHandler = null; + channel = plugin.getBungeeSettings().getPluginMessagingChannel(); + encryptionHandler = null; if (plugin.getBungeeSettings().isPluginMessageEncryption()) { encryptionHandler = new EncryptionHandler(plugin.getName(), new File(plugin.getDataFolder(), "secretkey.key")); } + debug = plugin.getBungeeSettings().isBungeeDebug(); + } + + @Override + public void activateAfterPublication() { + if (messageHandler != null && !active) { + publishSharedState(); + active = true; + } + } + + void restoreAfterFailedReplacement() { + if (messageHandler != null) { + publishSharedState(); + active = true; + } + } + + private void publishSharedState() { + if (plugin.getPluginMessaging() == null || !channel.equals(plugin.getBungeeChannel())) { + plugin.registerBungeeChannels(channel); + } plugin.getPluginMessaging().setEncryptionHandler(encryptionHandler); - plugin.getPluginMessaging().setDebug(plugin.getBungeeSettings().isBungeeDebug()); + plugin.getPluginMessaging().setDebug(debug); plugin.activateBackendPluginMessageHandler(messageHandler); } @Override - public void send(JsonEnvelope envelope) { + public boolean send(JsonEnvelope envelope) { plugin.getPluginMessaging().sendEnvelope(envelope); + return true; } @Override public void close() { - if (messageHandler != null) { + if (messageHandler != null && active) { plugin.deactivateBackendPluginMessageHandler(messageHandler); - messageHandler = null; } + active = false; + messageHandler = null; + channel = null; + encryptionHandler = null; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java index eb2ce2539..2e9de9272 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransport.java @@ -22,7 +22,29 @@ import lombok.Getter; public class RedisBackendProxyTransport implements BackendProxyTransport { + public static final class HandoffQuiescenceException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + public HandoffQuiescenceException(String message) { + super(message); + } + } + + public static final class HandoffReplayBackpressureException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + public HandoffReplayBackpressureException(String message) { + super(message); + } + } static final int MAX_LEGACY_HANDOFF_DELIVERIES = 4096; + static final int MAX_IDENTIFIED_HANDOFF_DELIVERIES = 4096; + static final int MAX_REPLAY_HANDOFF_DELIVERIES = MAX_LEGACY_HANDOFF_DELIVERIES + + MAX_IDENTIFIED_HANDOFF_DELIVERIES; + static final int REPLAY_BATCH_SIZE = 32; + private static final long HANDOFF_QUIESCE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3); + private static final long HANDOFF_REPLAY_BACKPRESSURE_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3); + private static final long REPLAY_BATCH_EXECUTION_TIMEOUT_NANOS = TimeUnit.SECONDS.toNanos(3); private final VotingPluginMain plugin; private final ProcessedVoteCache processedVoteCache; @@ -32,10 +54,33 @@ public class RedisBackendProxyTransport implements BackendProxyTransport { private Thread listenerThread; private final Object subscriberIdentity = new Object(); private final Object legacyLifecycle = new Object(); - private final List bufferedLegacyDeliveries = new ArrayList<>(); + private final List bufferedLegacyDeliveries = new ArrayList<>(); + private final List bufferedIdentifiedDeliveries = new ArrayList<>(); private long bufferedLegacyDeliveryBytes; + private long bufferedIdentifiedDeliveryBytes; + private long nextHandoffSequence; + private boolean standbySubscriber; + private boolean identifiedHandoffOverflowed; + private boolean legacyHandoffOverflowed; private boolean legacyHandoffDegraded; + private boolean retiredAfterHandoff; + private boolean replayingHandoff; + private boolean replayTransferFrozen; + private boolean replayBackpressureFailureLogged; + private long replayGeneration; + private Thread replayWorker; + private boolean replayTaskOutstanding; + private long replayTaskGeneration = -1L; + private int replayDeliveriesInFlight; + // Counts only handlers which have already crossed the Bukkit dispatch fence. + // It is deliberately independent of replayGeneration so cancellation cannot + // erase accounting for a callback that was already executing. + private int replayCallbacksInFlight; + private int dispatchesInFlight; + private final java.util.ArrayDeque deliveriesAfterReplay = new java.util.ArrayDeque<>(); private GlobalMessageHandler messageHandler; + private GlobalMessageHandler handoffMessageHandler; + private String publishChannel; public RedisBackendProxyTransport(VotingPluginMain plugin) { this(plugin, new ProcessedVoteCache()); @@ -49,7 +94,9 @@ public RedisBackendProxyTransport(VotingPluginMain plugin, ProcessedVoteCache pr @Override public void start(GlobalMessageHandler messageHandler) { this.messageHandler = messageHandler; - processedVoteCache.registerRedisSubscriber(subscriberIdentity); + publishChannel = plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin"; + retiredAfterHandoff = false; + standbySubscriber = !processedVoteCache.registerRedisSubscriber(subscriberIdentity); redisHandler = new RedisHandler(plugin.getBungeeSettings().getRedisHost(), plugin.getBungeeSettings().getRedisPort(), plugin.getBungeeSettings().getRedisUsername(), plugin.getBungeeSettings().getRedisPassword(), plugin.getBungeeSettings().getRedisdbindex(), @@ -70,11 +117,7 @@ public void debug(String message) { try { JsonEnvelope envelope = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.decode(payload); String deliveryId = envelope.getFields().get(VotingPluginWire.K_REDIS_DELIVERY_ID); - if (deliveryId != null) { - if (processedVoteCache.reserveRedisDelivery(deliveryId)) messageHandler.onMessage(envelope); - } else { - dispatchLegacy(envelope); - } + dispatchReceivedSubscriberEnvelope(envelope, deliveryId); } catch (Exception e) { plugin.debug("Redis decode failed: " + e.getMessage()); } @@ -89,25 +132,127 @@ public void onSubscribe(String channel, int subscribedChannels) { listenerThread.start(); } + /** + * Redis Pub/Sub has already consumed this payload when its callback is invoked. + * A bounded queue wait therefore cannot discard it: retry the same callback in + * place, applying TCP/Pub/Sub backpressure until FIFO replay capacity returns or + * this subscriber is deliberately fenced during shutdown/rollback. + */ + void dispatchReceivedSubscriberEnvelope(JsonEnvelope envelope, String deliveryId) { + while (true) { + try { + if (deliveryId != null) dispatchIdentified(envelope, deliveryId); + else dispatchLegacy(envelope); + return; + } catch (HandoffReplayBackpressureException retry) { + synchronized (legacyLifecycle) { + if (retiredAfterHandoff) return; + } + if (plugin != null) + plugin.debug("Redis handoff replay remains full; retaining the received Pub/Sub payload for retry"); + } + } + } + + void dispatchIdentified(JsonEnvelope envelope, String deliveryId) { + boolean accepted; + synchronized (legacyLifecycle) { + if (!awaitReplayTransferResolution()) return; + if (retiredAfterHandoff) return; + awaitReplayCapacity(); + if (retiredAfterHandoff) return; + if (standbySubscriber) { + bufferIdentifiedDelivery(envelope, deliveryId); + return; + } + accepted = processedVoteCache.reserveRedisDelivery(deliveryId); + if (accepted && replayingHandoff) { + enqueueReplayDelivery(envelope); + accepted = false; + } + if (accepted) dispatchesInFlight++; + } + if (accepted) dispatchTracked(envelope); + } + void dispatchLegacy(JsonEnvelope envelope) { - String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope); + // A mixed-version publisher may put the reliable-only identity on a payload + // that is delivered through the legacy path. Do not expose that identity to + // handlers or let it alter legacy duplicate accounting. + JsonEnvelope legacyEnvelope = withoutReliableFields(envelope); + String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(legacyEnvelope); int encodedBytes = ProcessedVoteCache.legacyRedisDeliveryBytes(signature); + boolean dispatch = false; synchronized (legacyLifecycle) { + if (!awaitReplayTransferResolution()) return; + if (retiredAfterHandoff) return; + awaitReplayCapacity(); + if (retiredAfterHandoff) return; if (processedVoteCache.reserveLegacyRedisDelivery(subscriberIdentity, signature)) { - messageHandler.onMessage(envelope); + dispatch = true; } else if (encodedBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES && bufferedLegacyDeliveries.size() < MAX_LEGACY_HANDOFF_DELIVERIES + && handoffDeliveryCount() < MAX_REPLAY_HANDOFF_DELIVERIES && bufferedLegacyDeliveryBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES - encodedBytes) { - bufferedLegacyDeliveries.add(envelope); + bufferedLegacyDeliveries.add(new BufferedHandoffDelivery( + nextHandoffSequence++, legacyEnvelope, signature, false)); bufferedLegacyDeliveryBytes += encodedBytes; } else { - if (!legacyHandoffDegraded && plugin != null) { - plugin.getLogger().warning("Redis legacy handoff exceeded its " + MAX_LEGACY_HANDOFF_DELIVERIES - + " delivery / " + ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES - + " byte buffer; temporarily degrading duplicate suppression"); + if (standbySubscriber) { + if (!legacyHandoffOverflowed && plugin != null) + plugin.getLogger().warning("Redis legacy handoff buffer is full; aborting the staged handoff"); + legacyHandoffOverflowed = true; + } else { + if (!legacyHandoffDegraded && plugin != null) { + plugin.getLogger().warning("Redis legacy handoff exceeded its " + MAX_LEGACY_HANDOFF_DELIVERIES + + " delivery / " + ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES + + " byte buffer; temporarily degrading duplicate suppression"); + } + legacyHandoffDegraded = true; + dispatch = true; } - legacyHandoffDegraded = true; - messageHandler.onMessage(envelope); + } + if (dispatch && replayingHandoff) { + enqueueReplayDelivery(envelope); + dispatch = false; + } + if (dispatch) dispatchesInFlight++; + } + if (dispatch) dispatchTracked(legacyEnvelope); + } + + private static JsonEnvelope withoutReliableFields(JsonEnvelope envelope) { + if (!envelope.getFields().containsKey(VotingPluginWire.K_REDIS_DELIVERY_ID)) return envelope; + JsonEnvelope.Builder builder = JsonEnvelope.builder(envelope.getSubChannel()).schema(envelope.getSchema()); + for (java.util.Map.Entry field : envelope.getFields().entrySet()) { + if (!VotingPluginWire.K_REDIS_DELIVERY_ID.equals(field.getKey())) builder.put(field.getKey(), field.getValue()); + } + return builder.build(); + } + + /** Holds a consumed subscriber callback until a same-Redis transfer commits or rolls back. */ + private boolean awaitReplayTransferResolution() { + boolean interrupted = false; + while (replayTransferFrozen) { + try { + legacyLifecycle.wait(); + } catch (InterruptedException waitInterrupted) { + // Do not drop a Pub/Sub payload merely because the listener was nudged + // during transfer. Its final owner will explicitly release this wait. + interrupted = true; + } + } + if (interrupted) Thread.currentThread().interrupt(); + return !retiredAfterHandoff; + } + + private void dispatchTracked(JsonEnvelope envelope) { + try { + messageHandler.onMessage(envelope); + } finally { + synchronized (legacyLifecycle) { + dispatchesInFlight--; + legacyLifecycle.notifyAll(); } } } @@ -115,29 +260,596 @@ void dispatchLegacy(JsonEnvelope envelope) { /** Promotes a validated standby after the previous listener has completely stopped. */ public void activateAfterHandoff() { synchronized (legacyLifecycle) { + if (identifiedHandoffOverflowed || legacyHandoffOverflowed + || processedVoteCache.isLegacyRedisHandoffOverflowed()) + throw new IllegalStateException("Redis handoff buffer overflowed before publication"); + replayingHandoff = true; + replayBackpressureFailureLogged = false; processedVoteCache.activateRedisSubscriber(subscriberIdentity); - for (JsonEnvelope envelope : bufferedLegacyDeliveries) { - String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope); - if (!processedVoteCache.consumeLegacyRedisDelivery(signature)) messageHandler.onMessage(envelope); + standbySubscriber = false; + java.util.ArrayList buffered = new java.util.ArrayList<>( + bufferedIdentifiedDeliveries.size() + bufferedLegacyDeliveries.size()); + buffered.addAll(bufferedIdentifiedDeliveries); + buffered.addAll(bufferedLegacyDeliveries); + buffered.sort(java.util.Comparator.comparingLong(BufferedHandoffDelivery::sequence)); + for (BufferedHandoffDelivery delivery : buffered) { + boolean dispatch; + try { + dispatch = delivery.identified() + ? processedVoteCache.reserveRedisDelivery(delivery.identity()) + : !processedVoteCache.consumeLegacyRedisDelivery(delivery.identity()); + } catch (RuntimeException replayFailure) { + if (plugin != null) plugin.debug("Redis handoff replay failed: " + replayFailure.getMessage()); + continue; + } + if (dispatch) enqueueReplayDelivery(delivery.envelope()); } + bufferedIdentifiedDeliveries.clear(); + bufferedIdentifiedDeliveryBytes = 0; bufferedLegacyDeliveries.clear(); bufferedLegacyDeliveryBytes = 0; + identifiedHandoffOverflowed = false; + legacyHandoffOverflowed = false; legacyHandoffDegraded = false; processedVoteCache.finishRedisHandoff(); } } + /** Replays buffered deliveries after the owning handler opens its publication gate. */ + public void replayAfterHandoffPublication() { + if (plugin == null) { + // Unit tests use a transport without a Bukkit scheduler. Production always + // takes the bounded worker path below. + drainReplayWithoutScheduler(); + return; + } + startReplayWorkerIfNeeded(); + } + + /** True while a same-Redis replay still owns accepted envelopes or Bukkit work. */ + public boolean hasPendingReplayForReplacement() { + synchronized (legacyLifecycle) { + return replayingHandoff || replayTransferFrozen || !deliveriesAfterReplay.isEmpty() + || replayDeliveriesInFlight != 0 || replayCallbacksInFlight != 0 || replayTaskOutstanding; + } + } + + /** + * Waits only on the Control/validation worker for active replay to finish + * before a non-Redis replacement can retire this transport. It never moves + * Redis envelopes into a transport that cannot preserve Redis semantics. + */ + public boolean awaitReplayDrainForNonRedisReplacement(long deadlineNanos) { + boolean interrupted = false; + synchronized (legacyLifecycle) { + while (hasPendingReplayForReplacement()) { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) { + if (interrupted) Thread.currentThread().interrupt(); + return false; + } + try { + TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining); + } catch (InterruptedException waitInterrupted) { + interrupted = true; + break; + } + } + } + if (interrupted) Thread.currentThread().interrupt(); + return !interrupted; + } + + private void startReplayWorkerIfNeeded() { + Thread worker; + synchronized (legacyLifecycle) { + if (!replayingHandoff || replayTransferFrozen || replayWorker != null || replayTaskOutstanding) return; + worker = new Thread(this::drainReplayOnWorker, "VotingPlugin-Redis-Backend-Replay"); + worker.setDaemon(true); + replayWorker = worker; + } + try { + worker.start(); + } catch (RuntimeException failed) { + synchronized (legacyLifecycle) { + if (replayWorker == worker) replayWorker = null; + legacyLifecycle.notifyAll(); + } + throw failed; + } + } + + private void enqueueReplayDelivery(JsonEnvelope envelope) { + if (deliveriesAfterReplay.size() + replayDeliveriesInFlight >= MAX_REPLAY_HANDOFF_DELIVERIES) + throw new IllegalStateException("Redis handoff replay capacity was not reserved"); + deliveriesAfterReplay.addLast(envelope); + } + + /** + * Preserves handoff FIFO without allowing the replay queue to grow without + * bound. A timeout is explicit and happens before the identified ID is + * reserved, so an undeliverable callback is never silently marked processed. + */ + private void awaitReplayCapacity() { + if (!replayingHandoff) return; + boolean interrupted = false; + long deadline = System.nanoTime() + HANDOFF_REPLAY_BACKPRESSURE_TIMEOUT_NANOS; + while (replayingHandoff && deliveriesAfterReplay.size() + replayDeliveriesInFlight + >= MAX_REPLAY_HANDOFF_DELIVERIES) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0L) { + if (!replayBackpressureFailureLogged && plugin != null) { + plugin.getLogger().severe("Redis handoff replay is not draining; rejected an unreserved callback"); + replayBackpressureFailureLogged = true; + } + if (interrupted) Thread.currentThread().interrupt(); + throw new HandoffReplayBackpressureException("Redis handoff replay queue did not drain before its deadline"); + } + try { + TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining); + } catch (InterruptedException waitInterrupted) { + interrupted = true; + break; + } + } + if (interrupted) { + Thread.currentThread().interrupt(); + throw new HandoffReplayBackpressureException("Interrupted while waiting for Redis handoff replay capacity"); + } + } + + private void drainReplayWithoutScheduler() { + while (true) { + java.util.ArrayList replay; + GlobalMessageHandler handler; + long generation; + synchronized (legacyLifecycle) { + if (!replayingHandoff || deliveriesAfterReplay.isEmpty()) { + replayingHandoff = false; + legacyLifecycle.notifyAll(); + return; + } + replay = takeReplayBatch(); + handler = messageHandler; + generation = replayGeneration; + } + dispatchReplayBatch(replay, handler, generation); + completeReplayBatch(replay.size()); + } + } + + /** Coordinates one bounded Bukkit batch at a time without blocking publication. */ + private void drainReplayOnWorker() { + try { + while (!Thread.currentThread().isInterrupted()) { + java.util.ArrayList replay; + GlobalMessageHandler handler; + long generation; + synchronized (legacyLifecycle) { + if (!replayingHandoff || deliveriesAfterReplay.isEmpty()) { + replayingHandoff = false; + legacyLifecycle.notifyAll(); + return; + } + if (replayTransferFrozen) return; + replay = takeReplayBatch(); + handler = messageHandler; + generation = replayGeneration; + } + ReplayBatch batch = new ReplayBatch(generation); + java.util.concurrent.CountDownLatch completed = new java.util.concurrent.CountDownLatch(1); + try { + synchronized (legacyLifecycle) { + replayTaskOutstanding = true; + replayTaskGeneration = batch.generation; + } + plugin.getBukkitScheduler().runTask(plugin, () -> { + try { + synchronized (legacyLifecycle) { + if (batch.generation != replayGeneration || batch.returned || !replayingHandoff) return; + if (replayTransferFrozen) return; + batch.started = true; + } + dispatchReplayBatch(replay, handler, batch.generation); + } finally { + synchronized (legacyLifecycle) { + if (batch.generation == replayGeneration && !batch.returned) { + if (!batch.started && replayTransferFrozen) { + batch.returned = true; + requeueReplayBatch(replay); + } else completeReplayBatch(replay.size()); + } + if (replayTaskOutstanding && replayTaskGeneration == batch.generation) { + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + } + legacyLifecycle.notifyAll(); + } + completed.countDown(); + startReplayWorkerIfNeeded(); + } + }); + if (!completed.await(REPLAY_BATCH_EXECUTION_TIMEOUT_NANOS, TimeUnit.NANOSECONDS)) { + synchronized (legacyLifecycle) { + if (!batch.started && !batch.returned && batch.generation == replayGeneration) { + batch.returned = true; + replayGeneration++; + requeueReplayBatch(replay); + // Do not leave replay owned by a Bukkit task that was accepted but + // never began. A task already running must retain ownership until its + // finally block completes; otherwise a second worker could overlap it + // and the older task could clear the newer task's shared state. + if (replayTaskOutstanding && replayTaskGeneration == batch.generation) { + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + } + } + legacyLifecycle.notifyAll(); + } + return; + } + } catch (InterruptedException interrupted) { + synchronized (legacyLifecycle) { + // A batch is removed before it can be scheduled. If the worker is + // interrupted while waiting for Bukkit, put an unstarted batch back + // at the front; otherwise the accepted deliveries would vanish. + if (!batch.started && !batch.returned && batch.generation == replayGeneration) { + batch.returned = true; + requeueReplayBatch(replay); + if (replayTaskOutstanding && replayTaskGeneration == batch.generation) { + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + } + } + legacyLifecycle.notifyAll(); + } + Thread.currentThread().interrupt(); + return; + } catch (RuntimeException schedulingFailure) { + synchronized (legacyLifecycle) { + if (!batch.returned && batch.generation == replayGeneration) { + batch.returned = true; + requeueReplayBatch(replay); + } + if (replayTaskOutstanding && replayTaskGeneration == batch.generation) { + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + } + legacyLifecycle.notifyAll(); + } + if (plugin != null) plugin.debug("Redis handoff replay scheduling failed: " + schedulingFailure.getMessage()); + try { + Thread.sleep(250L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return; + } + } + } + } finally { + synchronized (legacyLifecycle) { + if (replayWorker == Thread.currentThread()) replayWorker = null; + legacyLifecycle.notifyAll(); + } + startReplayWorkerIfNeeded(); + } + } + + private java.util.ArrayList takeReplayBatch() { + java.util.ArrayList replay = new java.util.ArrayList<>(REPLAY_BATCH_SIZE); + while (!deliveriesAfterReplay.isEmpty() && replay.size() < REPLAY_BATCH_SIZE) + replay.add(deliveriesAfterReplay.removeFirst()); + replayDeliveriesInFlight += replay.size(); + return replay; + } + + private void completeReplayBatch(int size) { + synchronized (legacyLifecycle) { + replayDeliveriesInFlight -= size; + if (replayDeliveriesInFlight < 0) replayDeliveriesInFlight = 0; + legacyLifecycle.notifyAll(); + } + } + + private void requeueReplayBatch(java.util.List replay) { + for (int index = replay.size() - 1; index >= 0; index--) deliveriesAfterReplay.addFirst(replay.get(index)); + completeReplayBatch(replay.size()); + } + + private static final class ReplayBatch { + private final long generation; + private boolean started; + private boolean returned; + + private ReplayBatch(long generation) { + this.generation = generation; + } + } + + private void dispatchReplayBatch(java.util.List replay, GlobalMessageHandler handler, + long generation) { + if (handler == null) return; + for (JsonEnvelope envelope : replay) { + synchronized (legacyLifecycle) { + // A close/fence may happen after Bukkit accepts the batch. Never let a + // stale generation start another callback after that point. + if (!replayingHandoff || generation != replayGeneration) return; + replayCallbacksInFlight++; + } + try { + handler.onMessage(envelope); + } catch (RuntimeException replayFailure) { + if (plugin != null) plugin.debug("Redis handoff replay failed: " + replayFailure.getMessage()); + } finally { + synchronized (legacyLifecycle) { + replayCallbacksInFlight--; + if (replayCallbacksInFlight < 0) replayCallbacksInFlight = 0; + legacyLifecycle.notifyAll(); + } + } + } + } + /** Stops the old listener while retaining its overlap accounting for standby promotion. */ public void closeForHandoff() { + handoffMessageHandler = messageHandler; + fenceAfterHandoff(); + // A successful fence guarantees this listener will not dispatch the held + // callback. Release it before listener shutdown so the Redis listener thread + // can return and join promptly; the overlapping standby owns its duplicate. + completeFrozenReplayTransfer(); closeListener(false); } + /** Reopens a fenced active subscriber when standby promotion is rejected. */ + public void restoreAfterFailedHandoff() { + restoreAfterFailedHandoff(java.util.Collections.emptyList()); + } + + /** + * Reinstates the retired subscriber and prepends deliveries accepted by a + * promoted replacement before publication later failed. Standby overlap IDs + * are resolved while detaching them, so this FIFO can replay directly exactly + * once after the old subscriber is restored. + */ + public void restoreAfterFailedHandoff(java.util.List replacementReplay) { + GlobalMessageHandler handler = handoffMessageHandler; + if (handler == null) throw new IllegalStateException("Redis handoff transport cannot be restored"); + Thread retiredListener = listenerThread; + if (retiredListener != null && retiredListener.isAlive()) + throw new IllegalStateException("Redis handoff listener did not stop before rollback restart"); + synchronized (legacyLifecycle) { + if (replacementReplay.size() > MAX_REPLAY_HANDOFF_DELIVERIES) + throw new IllegalStateException("Redis rollback replay exceeds its bounded handoff capacity"); + deliveriesAfterReplay.clear(); + deliveriesAfterReplay.addAll(replacementReplay); + replayDeliveriesInFlight = 0; + replayingHandoff = !replacementReplay.isEmpty(); + replayGeneration++; + retiredAfterHandoff = false; + } + processedVoteCache.restoreRedisSubscriber(subscriberIdentity); + start(handler); + if (!replacementReplay.isEmpty()) replayAfterHandoffPublication(); + handoffMessageHandler = null; + } + + /** Transfers pre-publication replay ownership to a restored predecessor. */ + public java.util.List detachReplayForFailedHandoff() { + synchronized (legacyLifecycle) { + if (replayDeliveriesInFlight != 0 || replayCallbacksInFlight != 0) + throw new IllegalStateException("Redis rollback cannot detach a replay batch already executing"); + java.util.ArrayList buffered = new java.util.ArrayList<>( + bufferedIdentifiedDeliveries.size() + bufferedLegacyDeliveries.size()); + buffered.addAll(bufferedIdentifiedDeliveries); + buffered.addAll(bufferedLegacyDeliveries); + buffered.sort(java.util.Comparator.comparingLong(BufferedHandoffDelivery::sequence)); + if (deliveriesAfterReplay.size() + buffered.size() > MAX_REPLAY_HANDOFF_DELIVERIES) + throw new IllegalStateException("Redis rollback replay exceeds its bounded handoff capacity"); + java.util.ArrayList pending = new java.util.ArrayList<>( + deliveriesAfterReplay.size() + buffered.size()); + pending.addAll(deliveriesAfterReplay); + for (BufferedHandoffDelivery delivery : buffered) { + boolean replay; + try { + replay = delivery.identified() + ? processedVoteCache.reserveRedisDelivery(delivery.identity()) + : !processedVoteCache.consumeLegacyRedisDelivery(delivery.identity()); + } catch (RuntimeException cacheFailure) { + // A rollback must keep an accepted staged callback. Retain it for the + // restored subscriber rather than letting a transient dedupe failure + // turn replacement close into data loss. + replay = true; + if (plugin != null) plugin.debug("Redis rollback dedupe failed: " + cacheFailure.getMessage()); + } + if (replay) pending.add(delivery.envelope()); + } + retiredAfterHandoff = true; + replayingHandoff = false; + replayTransferFrozen = false; + replayGeneration++; + deliveriesAfterReplay.clear(); + bufferedIdentifiedDeliveries.clear(); + bufferedIdentifiedDeliveryBytes = 0; + bufferedLegacyDeliveries.clear(); + bufferedLegacyDeliveryBytes = 0; + identifiedHandoffOverflowed = false; + legacyHandoffOverflowed = false; + legacyHandoffDegraded = false; + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + legacyLifecycle.notifyAll(); + return pending; + } + } + + /** + * Stops admitting new callbacks and transfers the remaining active replay FIFO + * to the next same-Redis standby. An already-started Bukkit batch is allowed + * to finish; an unstarted scheduled batch is returned to the deque first. + */ + public java.util.List freezeReplayForSuccessiveHandoff() { + boolean interrupted = false; + long deadline = System.nanoTime() + HANDOFF_QUIESCE_TIMEOUT_NANOS; + synchronized (legacyLifecycle) { + replayTransferFrozen = true; + retiredAfterHandoff = true; + Thread worker = replayWorker; + if (worker != null) worker.interrupt(); + while (replayDeliveriesInFlight != 0 || replayCallbacksInFlight != 0 || replayTaskOutstanding) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0L) { + resumeReplayAfterFailedSuccessiveHandoffLocked(); + if (interrupted) Thread.currentThread().interrupt(); + throw new HandoffQuiescenceException("Redis replay did not quiesce before successive handoff"); + } + try { + TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining); + } catch (InterruptedException waitInterrupted) { + interrupted = true; + resumeReplayAfterFailedSuccessiveHandoffLocked(); + Thread.currentThread().interrupt(); + throw new HandoffQuiescenceException("Interrupted while freezing Redis replay for successive handoff"); + } + } + java.util.ArrayList pending = new java.util.ArrayList<>(deliveriesAfterReplay); + deliveriesAfterReplay.clear(); + replayingHandoff = false; + replayGeneration++; + legacyLifecycle.notifyAll(); + if (interrupted) Thread.currentThread().interrupt(); + return pending; + } + } + + /** Prepends a predecessor's still-unplayed replay FIFO before this standby's own overlap buffer. */ + public void acceptReplayFromPreviousHandoff(java.util.List predecessorReplay) { + if (predecessorReplay == null || predecessorReplay.isEmpty()) return; + synchronized (legacyLifecycle) { + if (deliveriesAfterReplay.size() + replayDeliveriesInFlight + bufferedLegacyDeliveries.size() + + bufferedIdentifiedDeliveries.size() + predecessorReplay.size() + > MAX_REPLAY_HANDOFF_DELIVERIES) + throw new IllegalStateException("Redis replacement replay capacity is exhausted"); + for (JsonEnvelope envelope : predecessorReplay) deliveriesAfterReplay.addLast(envelope); + } + } + + /** Rolls back a not-yet-published predecessor transfer when its old listener cannot retire. */ + public void removeReplayFromPreviousHandoff(java.util.List predecessorReplay) { + if (predecessorReplay == null || predecessorReplay.isEmpty()) return; + synchronized (legacyLifecycle) { + if (deliveriesAfterReplay.size() < predecessorReplay.size()) + throw new IllegalStateException("Redis replacement replay transfer is incomplete"); + for (JsonEnvelope expected : predecessorReplay) { + JsonEnvelope actual = deliveriesAfterReplay.removeFirst(); + if (!java.util.Objects.equals(actual, expected)) + throw new IllegalStateException("Redis replacement replay FIFO changed during handoff rollback"); + } + } + } + + /** Finalizes a successful transfer and releases callbacks to the promoted standby overlap. */ + public void completeFrozenReplayTransfer() { + synchronized (legacyLifecycle) { + replayTransferFrozen = false; + legacyLifecycle.notifyAll(); + } + } + + /** Restores the frozen active FIFO when successor admission or retirement fails. */ + public void restoreFrozenReplayAfterFailedSuccessiveHandoff(java.util.List replay) { + synchronized (legacyLifecycle) { + if (!deliveriesAfterReplay.isEmpty()) + throw new IllegalStateException("Redis replay ownership changed while successive handoff was aborted"); + deliveriesAfterReplay.addAll(replay); + resumeReplayAfterFailedSuccessiveHandoffLocked(); + } + if (plugin != null) startReplayWorkerIfNeeded(); + } + + private void resumeReplayAfterFailedSuccessiveHandoffLocked() { + replayTransferFrozen = false; + retiredAfterHandoff = false; + if (!deliveriesAfterReplay.isEmpty()) replayingHandoff = true; + legacyLifecycle.notifyAll(); + } + + /** Prevents a listener that misses its shutdown deadline from dispatching duplicates. */ + void fenceAfterHandoff() { + boolean interrupted = false; + long deadline = System.nanoTime() + HANDOFF_QUIESCE_TIMEOUT_NANOS; + synchronized (legacyLifecycle) { + retiredAfterHandoff = true; + replayGeneration++; + Thread worker = replayWorker; + if (worker != null) worker.interrupt(); + replayWorker = null; + while (dispatchesInFlight > 0) { + long remaining = deadline - System.nanoTime(); + if (remaining <= 0) break; + try { + TimeUnit.NANOSECONDS.timedWait(legacyLifecycle, remaining); + } catch (InterruptedException waitInterrupted) { + interrupted = true; + break; + } + } + if (dispatchesInFlight > 0) { + retiredAfterHandoff = false; + if (interrupted) Thread.currentThread().interrupt(); + throw new HandoffQuiescenceException( + "Redis backend callbacks did not quiesce before handoff"); + } + replayingHandoff = false; + deliveriesAfterReplay.clear(); + replayBackpressureFailureLogged = false; + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + replayDeliveriesInFlight = 0; + bufferedLegacyDeliveries.clear(); + bufferedLegacyDeliveryBytes = 0; + bufferedIdentifiedDeliveries.clear(); + bufferedIdentifiedDeliveryBytes = 0; + identifiedHandoffOverflowed = false; + legacyHandoffOverflowed = false; + } + if (interrupted) Thread.currentThread().interrupt(); + } + + private void bufferIdentifiedDelivery(JsonEnvelope envelope, String deliveryId) { + String signature = com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec.encode(envelope); + int encodedBytes = ProcessedVoteCache.legacyRedisDeliveryBytes(signature); + if (encodedBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + && bufferedIdentifiedDeliveries.size() < MAX_IDENTIFIED_HANDOFF_DELIVERIES + && handoffDeliveryCount() < MAX_REPLAY_HANDOFF_DELIVERIES + && bufferedIdentifiedDeliveryBytes <= ProcessedVoteCache.MAX_LEGACY_REDIS_TOTAL_BYTES - encodedBytes) { + bufferedIdentifiedDeliveries.add(new BufferedHandoffDelivery( + nextHandoffSequence++, envelope, deliveryId, true)); + bufferedIdentifiedDeliveryBytes += encodedBytes; + } else { + if (plugin != null && !identifiedHandoffOverflowed) + plugin.getLogger().warning("Redis identified handoff buffer is full; aborting the staged handoff"); + identifiedHandoffOverflowed = true; + } + } + + /** Includes inherited replay plus staged overlap entries so rollback remains bounded. */ + private int handoffDeliveryCount() { + return deliveriesAfterReplay.size() + replayDeliveriesInFlight + + bufferedIdentifiedDeliveries.size() + bufferedLegacyDeliveries.size(); + } + + private record BufferedHandoffDelivery(long sequence, JsonEnvelope envelope, String identity, + boolean identified) {} + @Override - public void send(JsonEnvelope envelope) { + public boolean send(JsonEnvelope envelope) { if (redisHandler != null) { - redisHandler.publishEnvelope(plugin.getBungeeSettings().getRedisPrefix() + "VotingPlugin", + redisHandler.publishEnvelope(publishChannel, VotingPluginWire.withRedisDeliveryId(envelope)); + return true; } + return false; } @Override @@ -179,9 +891,28 @@ static DefaultJedisClientConfig buildValidationClientConfig(int database, String @Override public void close() { + cancelReplay(); closeListener(true); } + private void cancelReplay() { + synchronized (legacyLifecycle) { + retiredAfterHandoff = true; + replayGeneration++; + replayingHandoff = false; + replayTransferFrozen = false; + deliveriesAfterReplay.clear(); + replayBackpressureFailureLogged = false; + replayTaskOutstanding = false; + replayTaskGeneration = -1L; + replayDeliveriesInFlight = 0; + Thread worker = replayWorker; + replayWorker = null; + if (worker != null) worker.interrupt(); + legacyLifecycle.notifyAll(); + } + } + private void closeListener(boolean unregister) { Thread thread = listenerThread; if (redisHandler != null) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java index a660c8874..da30de584 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/backendproxy/transport/SocketBackendProxyTransport.java @@ -1,6 +1,9 @@ package com.bencodez.votingplugin.backendproxy.transport; import java.io.File; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; import com.bencodez.simpleapi.encryption.EncryptionHandler; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; @@ -8,6 +11,7 @@ import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; import com.bencodez.simpleapi.servercomm.sockets.SocketHandler; import com.bencodez.simpleapi.servercomm.sockets.SocketReceiver; +import com.bencodez.simpleapi.servercomm.sockets.SocketServer; import com.bencodez.votingplugin.VotingPluginMain; import lombok.Getter; @@ -19,6 +23,13 @@ public class SocketBackendProxyTransport implements BackendProxyTransport { private ClientHandler clientHandler; @Getter private SocketHandler socketHandler; + private GlobalMessageHandler messageHandler; + private EncryptionHandler encryptionHandler; + private String bungeeHost; + private int bungeePort; + private String spigotHost; + private int spigotPort; + private boolean debug; public SocketBackendProxyTransport(VotingPluginMain plugin) { this.plugin = plugin; @@ -26,32 +37,62 @@ public SocketBackendProxyTransport(VotingPluginMain plugin) { @Override public void start(GlobalMessageHandler messageHandler) { - EncryptionHandler encryptionHandler = new EncryptionHandler(plugin.getName(), + this.messageHandler = messageHandler; + encryptionHandler = new EncryptionHandler(plugin.getName(), new File(plugin.getDataFolder(), "secretkey.key")); - clientHandler = new ClientHandler(plugin.getBungeeSettings().getBungeeServerHost(), - plugin.getBungeeSettings().getBungeeServerPort(), encryptionHandler, - plugin.getBungeeSettings().isBungeeDebug()); - socketHandler = new SocketHandler("vp-socket", plugin.getBungeeSettings().getSpigotServerHost(), - plugin.getBungeeSettings().getSpigotServerPort(), encryptionHandler, - plugin.getBungeeSettings().isBungeeDebug()) { - @Override - public void log(String str) { - plugin.getLogger().info(str); - } - }; - socketHandler.add(new SocketReceiver() { - @Override - public void onReceiveEnvelope(JsonEnvelope envelope) { - messageHandler.onMessage(envelope); - } - }); + bungeeHost = plugin.getBungeeSettings().getBungeeServerHost(); + bungeePort = plugin.getBungeeSettings().getBungeeServerPort(); + spigotHost = plugin.getBungeeSettings().getSpigotServerHost(); + spigotPort = plugin.getBungeeSettings().getSpigotServerPort(); + debug = plugin.getBungeeSettings().isBungeeDebug(); + startConnections(); + } + + private void startConnections() { + clientHandler = new ClientHandler(bungeeHost, bungeePort, encryptionHandler, debug); + try { + verifyListenerPortAvailable(); + socketHandler = new SocketHandler("vp-socket", spigotHost, spigotPort, encryptionHandler, debug) { + @Override + public void log(String str) { + plugin.getLogger().info(str); + } + }; + socketHandler.add(new SocketReceiver() { + @Override + public void onReceiveEnvelope(JsonEnvelope envelope) { + messageHandler.onMessage(envelope); + } + }); + } catch (RuntimeException failure) { + clientHandler.stopConnection(); + clientHandler = null; + throw failure; + } + } + + /** + * SocketHandler logs and closes itself when its constructor cannot bind. Probe + * first so replacement validation cannot mistake that swallowed failure for a + * live listener. + */ + private void verifyListenerPortAvailable() { + try (ServerSocket probe = new ServerSocket()) { + probe.setReuseAddress(false); + probe.bind(new InetSocketAddress(spigotHost, spigotPort)); + } catch (IOException unavailable) { + throw new IllegalStateException("Socket backend proxy listener is unavailable at " + spigotHost + ":" + + spigotPort, unavailable); + } } @Override - public void send(JsonEnvelope envelope) { + public boolean send(JsonEnvelope envelope) { if (clientHandler != null) { clientHandler.sendEnvelope(envelope); + return true; } + return false; } @Override @@ -63,13 +104,64 @@ public void validate() { @Override public void close() { - if (socketHandler != null) { - socketHandler.closeConnection(); - socketHandler = null; + close(false); + } + + @Override + public void prepareForReplacement() { + close(true); + } + + private void close(boolean strict) { + RuntimeException failure = null; + try { + closeSocketListener(); + } catch (RuntimeException listenerFailure) { + failure = listenerFailure; } - if (clientHandler != null) { - clientHandler.stopConnection(); - clientHandler = null; + ClientHandler closingClient = clientHandler; + clientHandler = null; + if (closingClient != null) try { + closingClient.stopConnection(); + } catch (RuntimeException clientFailure) { + if (failure == null) failure = clientFailure; + else failure.addSuppressed(clientFailure); + } + if (failure == null) return; + if (strict) throw failure; + if (plugin != null && plugin.getLogger() != null) { + plugin.getLogger().warning("Socket backend proxy transport did not stop cleanly"); + plugin.debug(failure); + } + } + + private void closeSocketListener() { + SocketHandler closing = socketHandler; + socketHandler = null; + if (closing == null) return; + SocketServer server = closing.getServer(); + closing.closeConnection(); + if (server == null) return; + try { + server.join(1000L); + if (server.isAlive()) { + throw new IllegalStateException("Socket backend proxy listener did not stop before replacement"); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while retiring the socket backend proxy listener", interrupted); + } finally { + // SocketServer's accept-error recovery can race close() and re-bind once + // before it observes its stopped flag. Always close the final socket, even + // after a timeout or interruption leaves the worker incomplete. + server.close(); + } + } + + public void restoreAfterFailedReplacement() { + if (messageHandler == null || encryptionHandler == null) { + throw new IllegalStateException("Socket backend proxy transport cannot be restored before startup"); } + startConnections(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java index b9f8b1afc..5c8a05ab7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/BungeeSettings.java @@ -88,7 +88,11 @@ public class BungeeSettings extends YMLFile { @ConfigDataInt(path = "BungeeServer.Port") @Getter - private int bungeeServerPort = 1297; + private int bungeeServerPort = 1297; + + @ConfigDataString(path = "HTTP.ConnectionCode") + @Getter + private String httpConnectionCode = ""; @ConfigDataBoolean(path = "PerServerPoints") @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index f4722b261..18ef84393 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -36,6 +36,7 @@ import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.YamlConfiguration; +import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.util.DurableFiles; @@ -59,7 +60,7 @@ public final class BackendConfigurationService { "WaitUntilVoteDelay", "PermissionToView", "IgnoreCanVote", "VoteDelayDailyHour", "VoteDelayMin", "GiveOffline"); private static final Pattern COMMENT_SECRET = Pattern.compile( - "(?i)([\"']?\\b(?:[\\w-]*(?:password|secret|user(?:name)?)[\\w-]*|token|api[ _.-]?key|authorization|[\\w.-]*webhook[ _.-]?url)" + "(?i)([\"']?\\b(?:[\\w-]*(?:password|secret|user(?:name)?)[\\w-]*|token|connection[ _.-]?code|api[ _.-]?key|authorization|[\\w.-]*webhook[ _.-]?url)" + "\\b[\"']?\\s*[:=]\\s*)(.*)$"); private static final Pattern SECRET_PATH_URL = Pattern.compile("(?i)([\"']?\\burl\\b[\"']?\\s*[:=]\\s*)(.*)$"); private static final Pattern BLOCK_SCALAR_INDICATOR = Pattern.compile("[|>](?:[+-][1-9]?|[1-9][+-]?)?"); @@ -289,6 +290,7 @@ private ApplyResult apply(String fileName, String proposedContent, String expect Path staging = Files.createTempFile(target.getParent(), ".control-", ".yml"); Path backupStaging = Files.createTempFile(target.getParent(), ".control-backup-", ".yml"); boolean installed = false; + boolean reloadAttempted = false; try { Files.writeString(staging, preview.resolvedContent(), StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); @@ -305,6 +307,7 @@ private ApplyResult apply(String fileName, String proposedContent, String expect installed = true; throw published; } + reloadAttempted = true; applyAction.run(fileName); String applied = readRaw(target, false); String installedRevision = revision(preview.resolvedContent()); @@ -337,7 +340,7 @@ private ApplyResult apply(String fileName, String proposedContent, String expect failure.addSuppressed(rollbackFailure); } } - throw new ApplyFailureException(rolledBack, failure); + throw new ApplyFailureException(rolledBack, reloadAttempted, failure); } finally { Files.deleteIfExists(staging); Files.deleteIfExists(backupStaging); @@ -374,6 +377,7 @@ private synchronized ApplyResult applyNamedReward(String fileName, String propos String rollbackStaging = null; String backup = name + ".control-backup"; boolean installed = false; + boolean reloadAttempted = false; try { writeRaw(rewards, staging, preview.resolvedContent(), true); rejectSymbolicBackup(rewards, backup); @@ -385,6 +389,7 @@ private synchronized ApplyResult applyNamedReward(String fileName, String propos rewards.move(Path.of(staging), rewards, Path.of(name)); installed = true; forcePinnedRewardDirectory(rewards, directoryKey); + reloadAttempted = true; applyAction.runNamedReward(fileName, preview.resolvedContent(), () -> requireCurrentRewardDirectory(directoryKey)); requireCurrentRewardDirectory(directoryKey); @@ -408,9 +413,10 @@ private synchronized ApplyResult applyNamedReward(String fileName, String propos if (!revision(readRaw(rewards, name, false)).equals(revision(preview.resolvedContent()))) { throw new IOException("Managed configuration changed while rollback was staged"); } - rewards.move(Path.of(rollbackStaging), rewards, Path.of(name)); - forcePinnedRewardDirectory(rewards, directoryKey); - applyAction.runNamedReward(fileName, current, + rewards.move(Path.of(rollbackStaging), rewards, Path.of(name)); + forcePinnedRewardDirectory(rewards, directoryKey); + reloadAttempted = true; + applyAction.runNamedReward(fileName, current, () -> requireCurrentRewardDirectory(directoryKey)); requireCurrentRewardDirectory(directoryKey); rolledBack = true; @@ -418,7 +424,7 @@ private synchronized ApplyResult applyNamedReward(String fileName, String propos failure.addSuppressed(rollbackFailure); } } - throw new ApplyFailureException(rolledBack, failure); + throw new ApplyFailureException(rolledBack, reloadAttempted, failure); } finally { deleteIfPresent(rewards, staging); deleteIfPresent(rewards, backupStaging); @@ -1061,6 +1067,11 @@ private void validateProxyMethod(BungeeMethod method, YamlConfiguration settings case SOCKETS: configuredHostAndPort(settings, "BungeeServer.Host", "BungeeServer.Port", 1297, "BungeeServer"); break; + case HTTP: + String connectionCode = settings.getString("HTTP.ConnectionCode", ""); + try { HttpBackendProxyTransport.validateConfiguration(dataDirectory.resolve("http"), server, connectionCode); } + catch (IllegalStateException invalid) { throw new IllegalArgumentException(invalid.getMessage(), invalid); } + break; case MYSQL: try { YamlConfiguration main = parse(readRaw(resolve("Config.yml"), false)); @@ -1275,24 +1286,31 @@ private static boolean rewardFileName(String name) { } private static String readRaw(Path path, boolean allowMissing) throws IOException { - if (allowMissing && !Files.exists(path, LinkOption.NOFOLLOW_LINKS)) return ""; + if (!Files.exists(path, LinkOption.NOFOLLOW_LINKS)) { + if (allowMissing) return ""; + throw new java.nio.file.NoSuchFileException(path.toString()); + } if (!Files.isRegularFile(path, LinkOption.NOFOLLOW_LINKS)) { - throw new IOException("configuration file is missing or too large"); + throw new ConfigurationReadException(ConfigurationReadFailure.UNSAFE, + "configuration file is not a regular file"); } byte[] bytes; // Keep the no-follow check attached to the opened file. A separate Files.size/readAllBytes // would follow a link swapped in after the regular-file check. try (SeekableByteChannel channel = Files.newByteChannel(path, Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { - if (channel.size() > MAX_CONTENT_BYTES) throw new IOException("configuration file is missing or too large"); + if (channel.size() > MAX_CONTENT_BYTES) throw new ConfigurationReadException( + ConfigurationReadFailure.TOO_LARGE, "configuration file exceeds the managed size limit"); bytes = Channels.newInputStream(channel).readNBytes(MAX_CONTENT_BYTES + 1); - if (bytes.length > MAX_CONTENT_BYTES) throw new IOException("configuration file is missing or too large"); + if (bytes.length > MAX_CONTENT_BYTES) throw new ConfigurationReadException( + ConfigurationReadFailure.TOO_LARGE, "configuration file exceeds the managed size limit"); } try { return StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) .onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString(); } catch (CharacterCodingException e) { - throw new IOException("configuration file is not valid UTF-8", e); + throw new ConfigurationReadException(ConfigurationReadFailure.INVALID_ENCODING, + "configuration file is not valid UTF-8", e); } } @@ -1478,7 +1496,7 @@ private static List restoreCommentSecrets(List proposed, List CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", "config.quick-setup.v1", "config.quick-setup.v2", "config.vote-sites-sync.v1", - "config.proxy-method.v1", "config.reward-files.v1", "data.inspect.v1"); + "config.proxy-method.v1", "config.proxy-method.v2", "config.reward-files.v1", "data.inspect.v1"); + private static final Set DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId"); private final VotingPluginMain plugin; private final Path dataDirectory; @@ -65,27 +70,33 @@ public final class BackendControlConnector implements AutoCloseable { private final HttpClient http; private final BackendConfigurationService configurations; private final ControlInspectionService inspections; + private final PluginDeploymentService deployments; + private final boolean directLocalDeploymentEndpoint; private final UUID sessionId = UUID.randomUUID(); private final Map completed = new LinkedHashMap<>(); private final boolean recovering; private final AtomicBoolean running = new AtomicBoolean(); private final AtomicBoolean inspecting = new AtomicBoolean(); private final Object operationLifecycle = new Object(); + private final AtomicReference pendingBackendProxyRollback = new AtomicReference<>(); private final Object journalLifecycle = new Object(); private volatile boolean closed; private volatile boolean registered; private volatile boolean operationsAccepted; private volatile boolean quickSetupsAccepted; + private volatile boolean proxyMethodV2Accepted; private volatile boolean votePartySetupsAccepted; private volatile boolean voteSitesSyncAccepted; private volatile boolean rewardFilesAccepted; private volatile boolean inspectionsAccepted; + private volatile boolean deploymentsAccepted; private volatile int inspectionFailures; private volatile long inspectionRetryAtNanos; private volatile int failures; private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; private volatile ScheduledFuture inspectionPolling; + private volatile ScheduledFuture deploymentPolling; private volatile Future activeReload; private volatile CompletableFuture activeOperation; @@ -128,23 +139,168 @@ private BackendControlConnector(VotingPluginMain plugin, Path dataDirectory, Set } }); inspections = new ControlInspectionService(plugin); + directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( + settings.endpoint().toString(), hostedConfiguration); + PluginDeploymentService prepared = null; + boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( + settings.endpoint(), directLocalDeploymentEndpoint); + if (!recovering && deploymentEndpointAllowed) { + try { + prepared = PluginDeploymentService.backend(plugin.getServer().getUpdateFolderFile().toPath(), + plugin.getLoadedPluginJarFile().toPath()); + } catch (Exception failure) { + plugin.getLogger().warning("[Control] Plugin deployment staging is unavailable; capability not advertised"); + } + } else if (!recovering && !deploymentEndpointAllowed) { + plugin.getLogger().warning("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node"); + } + deployments = prepared; } private void reloadConfiguration(String fileName, String expectedContent) throws Exception { - reloadConfiguration(fileName, expectedContent, null); + reloadConfiguration(fileName, expectedContent, null, false); } private void reloadConfiguration(String fileName, String expectedContent, BackendConfigurationService.NamedRewardGuard guard) throws Exception { - reloadOnServerThread(() -> { - // Publication happens off-thread, but the plugin reads Rewards by path - // on this owner thread. Recheck the pinned identity at that boundary. - if (guard != null) guard.verify(); - plugin.reloadFromControl(); - if (guard != null) guard.verify(); - if (BackendConfigurationService.managedRewardFile(fileName)) verifyNamedRewardLoaded(fileName, expectedContent); - if ("BungeeSettings.yml".equals(fileName)) plugin.restartBackendProxyHandler(); - }); + reloadConfiguration(fileName, expectedContent, guard, false); + } + + /** Reuses the split restart lifecycle while retaining proxy-method's narrow reload scope. */ + private void reloadConfiguration(String fileName, String expectedContent, + BackendConfigurationService.NamedRewardGuard guard, boolean proxyMethodOnly) throws Exception { + finishPendingBackendProxyRollback(); + long validationDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(29); + AtomicBoolean preparationAbandoned = new AtomicBoolean(); + AtomicInteger preparationState = new AtomicInteger(); + CountDownLatch preparationSettled = new CountDownLatch(1); + CountDownLatch rollbackAbortSettled = new CountDownLatch(1); + AtomicReference preparedRestart = new AtomicReference<>(); + Future preparation; + synchronized (operationLifecycle) { + if (closed) throw new IllegalStateException("Bukkit Control connector is stopping"); + preparation = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + try { + if (!preparationState.compareAndSet(0, 1)) return null; + if (guard != null) guard.verify(); + if (!proxyMethodOnly) plugin.reloadFromControl(); + if (guard != null) guard.verify(); + if (BackendConfigurationService.managedRewardFile(fileName)) { + verifyNamedRewardLoaded(fileName, expectedContent); + } + VotingPluginMain.BackendProxyRestart prepared; + try { + if (!"BungeeSettings.yml".equals(fileName)) prepared = null; + else if (proxyMethodOnly) prepared = plugin.prepareBackendProxyMethodRestartFromControl(); + else prepared = plugin.prepareBackendProxyHandlerRestart(); + } catch (VotingPluginMain.BackendProxyRestartPreparationException failure) { + preparedRestart.set(failure.restart()); + throw failure; + } + preparedRestart.set(prepared); + return prepared; + } finally { + try { + VotingPluginMain.BackendProxyRestart prepared = preparedRestart.get(); + if (preparationAbandoned.get()) { + // The Control worker performs transport restoration after this + // Bukkit callback has marked the staged handler unavailable. + if (prepared != null && prepared.requiresWorkerRollback()) { + plugin.requestBackendProxyHandlerRestartAbandonment(prepared); + } else if (prepared != null) { + try { + plugin.abortBackendProxyHandlerRestart(prepared); + } finally { rollbackAbortSettled.countDown(); } + } + } + } finally { + preparationState.set(2); + preparationSettled.countDown(); + } + } + }); + activeReload = preparation; + } + VotingPluginMain.BackendProxyRestart restart = null; + Future publication = null; + try { + restart = preparation.get(remaining(validationDeadline), TimeUnit.NANOSECONDS); + if (restart == null) return; + // Network enrollment/readiness is deliberately awaited on this Control worker, + // never on Bukkit's primary thread. + plugin.validateBackendProxyHandlerRestart(restart, validationDeadline); + VotingPluginMain.BackendProxyRestart prepared = restart; + publication = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + plugin.completeBackendProxyHandlerRestart(prepared); + return null; + }); + synchronized (operationLifecycle) { + if (closed) publication.cancel(true); + activeReload = publication; + } + publication.get(remaining(validationDeadline), TimeUnit.NANOSECONDS); + } catch (Exception failure) { + // Timed-out Bukkit work must not remain queued ahead of configuration rollback. + preparationAbandoned.set(true); + boolean abandonedBeforePreparation = preparationState.compareAndSet(0, 3); + preparation.cancel(false); + boolean publicationCommitted = publication != null && restart != null + && !plugin.requestBackendProxyHandlerRestartAbandonment(restart); + if (publication != null) publication.cancel(false); + // A publication that won the synchronized commit race is the successful + // runtime state; rolling its YAML back would create a split-brain config. + if (publicationCommitted) return; + PendingBackendProxyRollback pendingRollback = null; + if (restart == null && !abandonedBeforePreparation) { + pendingRollback = new PendingBackendProxyRollback(preparationSettled, rollbackAbortSettled, preparedRestart); + pendingBackendProxyRollback.set(pendingRollback); + try { + if (!preparationSettled.await(40, TimeUnit.SECONDS)) + throw new java.util.concurrent.TimeoutException( + "Bukkit configuration preparation did not settle during rollback"); + } catch (Exception preparationFailure) { + failure.addSuppressed(preparationFailure); + throw failure; + } + } + if (restart == null) restart = preparedRestart.get(); + if (restart != null) { + if (pendingRollback == null) { + pendingRollback = new PendingBackendProxyRollback( + preparationSettled, rollbackAbortSettled, preparedRestart); + pendingBackendProxyRollback.set(pendingRollback); + } + VotingPluginMain.BackendProxyRestart prepared = restart; + try { + if (prepared.requiresWorkerRollback()) { + // This worker owns the exclusive socket/MQTT teardown and restoration. + // Bukkit state was already fenced by requestBackendProxyHandlerRestartAbandonment. + plugin.abortBackendProxyHandlerRestart(prepared); + rollbackAbortSettled.countDown(); + } else { + Future abort = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { + try { plugin.abortBackendProxyHandlerRestart(prepared); } + finally { rollbackAbortSettled.countDown(); } + return null; + }); + abort.get(5, TimeUnit.SECONDS); + } + finishPendingBackendProxyRollback(); + } catch (Exception cleanupFailure) { + rollbackAbortSettled.countDown(); + failure.addSuppressed(cleanupFailure); + } + } + if (pendingRollback != null && restart == null) { + rollbackAbortSettled.countDown(); + pendingBackendProxyRollback.compareAndSet(pendingRollback, null); + } + throw failure; + } finally { + synchronized (operationLifecycle) { + if (activeReload == preparation || activeReload == publication) activeReload = null; + } + } } private void reloadConfiguration(String fileName) throws Exception { @@ -194,26 +350,40 @@ private static boolean sameConfigurationValues(ConfigurationSection source, Conf } private void reloadProxyMethod(String ignored) throws Exception { - reloadOnServerThread(plugin::reloadBackendProxyMethodFromControl); - } - - private void reloadOnServerThread(ThrowingRunnable action) throws Exception { - Future reload; - synchronized (operationLifecycle) { - if (closed) throw new IllegalStateException("Bukkit Control connector is stopping"); - reload = plugin.getServer().getScheduler().callSyncMethod(plugin, () -> { action.run(); return null; }); - activeReload = reload; - } - try { - reload.get(30, TimeUnit.SECONDS); - } finally { - synchronized (operationLifecycle) { - if (activeReload == reload) activeReload = null; - } + // Proxy-method APPLY can select HTTP or Redis. Reuse the BungeeSettings + // split lifecycle so Bukkit only prepares/publishes while validation and + // bounded transport handoff waits remain on this connector worker. + reloadConfiguration("BungeeSettings.yml", null, null, true); + } + + private void finishPendingBackendProxyRollback() throws Exception { + PendingBackendProxyRollback pending = pendingBackendProxyRollback.get(); + if (pending == null) return; + if (!pending.preparationSettled().await(40, TimeUnit.SECONDS)) + throw new java.util.concurrent.TimeoutException( + "Previous Bukkit configuration preparation is still rolling back"); + VotingPluginMain.BackendProxyRestart restart = pending.preparedRestart().get(); + if (restart != null) { + if (!pending.rollbackAbortSettled().await(40, TimeUnit.SECONDS)) + throw new java.util.concurrent.TimeoutException( + "Previous Bukkit configuration abort is still pending"); + // Keep credential-journal I/O off Bukkit's primary thread and finish it + // before the configuration service starts its automatic backup reload. + plugin.awaitBackendProxyHandlerRollback(restart, + System.nanoTime() + TimeUnit.SECONDS.toNanos(40)); } + pendingBackendProxyRollback.compareAndSet(pending, null); } - @FunctionalInterface private interface ThrowingRunnable { void run() throws Exception; } + private record PendingBackendProxyRollback(CountDownLatch preparationSettled, + CountDownLatch rollbackAbortSettled, + AtomicReference preparedRestart) { } + + private static long remaining(long deadlineNanos) throws java.util.concurrent.TimeoutException { + long remaining = deadlineNanos - System.nanoTime(); + if (remaining <= 0L) throw new java.util.concurrent.TimeoutException("Bukkit configuration reload timed out"); + return remaining; + } public static BackendControlConnector create(VotingPluginMain plugin) throws IOException { Path root = plugin.getDataFolder().toPath().toAbsolutePath().normalize(); @@ -268,6 +438,76 @@ public void start() { OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); inspectionPolling = inspectionExecutor.scheduleWithFixedDelay(this::pollInspections, OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + if (deployments != null) { + deploymentPolling = executor.scheduleWithFixedDelay(this::pollDeployments, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + } + + /** Deployment is a separate leased lane; its large I/O never runs on Bukkit's primary thread. */ + private void pollDeployments() { + if (closed || deployments == null || !registered || failures != 0 || !deploymentsAccepted + || !running.compareAndSet(false, true)) return; + CompletableFuture operation = new CompletableFuture<>(); + synchronized (operationLifecycle) { + if (closed) { + running.set(false); + return; + } + activeOperation = operation; + } + try { + claimAndDeploy(); + } catch (Exception failure) { + registered = false; + failures = Math.min(30, failures + 1); + if (failures == 1 || failures % 10 == 0) { + plugin.getLogger().warning("[Control] Bukkit deployment polling unavailable; VotingPlugin remains active"); + } + ScheduledFuture heartbeat = scheduled; + if (heartbeat != null) heartbeat.cancel(false); + if (!closed) schedule(Math.min(TimeUnit.MINUTES.toMillis(5), + 1000L << Math.min(failures - 1, 8))); + } finally { + operation.complete(null); + synchronized (operationLifecycle) { + if (activeOperation == operation) activeOperation = null; + } + running.set(false); + } + } + + private void claimAndDeploy() throws Exception { + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + Response response = send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments", body); + if (response.status() == 204 || closed) return; + JsonObject claimed = requireObject(response, 200); + PluginDeploymentService.Task task = deploymentTask(claimed); + PluginDeploymentService.Result result = deployments.deploy(task, settings.endpoint(), directLocalDeploymentEndpoint, + settings.nodeId(), sessionId, credential, http, Duration.ofMillis(settings.requestTimeoutMillis()), + () -> !closed); + if (closed) return; + JsonObject submitted = new JsonObject(); + submitted.addProperty("sessionId", sessionId.toString()); + submitted.addProperty("success", result.success()); + submitted.addProperty("code", result.code()); + submitted.addProperty("message", boundedResultMessage(result.message())); + submitted.addProperty("attemptId", task.attemptId().toString()); + requireObject(send("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments/" + task.deploymentId() + + "/result", submitted), 200); + } + + static PluginDeploymentService.Task deploymentTask(JsonObject task) { + try { + if (task == null || task.size() != DEPLOYMENT_TASK_FIELDS.size() + || !DEPLOYMENT_TASK_FIELDS.containsAll(task.keySet())) throw new IllegalArgumentException(); + return new PluginDeploymentService.Task(UUID.fromString(string(task, "deploymentId")), + string(task, "artifactId"), string(task, "sha256"), Long.parseLong(string(task, "size")), + UUID.fromString(string(task, "attemptId"))); + } catch (RuntimeException failure) { + throw new IllegalArgumentException("deployment task is invalid"); + } } /** Polls the separately negotiated read-only lane on the connector worker. */ @@ -387,12 +627,15 @@ private void cycle() { } operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); + proxyMethodV2Accepted = negotiatedCapability(node, "config.proxy-method.v2", proxyMethodV2Accepted); votePartySetupsAccepted = negotiatedCapability(node, "config.quick-setup.v2", votePartySetupsAccepted); voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); rewardFilesAccepted = configurations.supportsNamedRewardFiles() && negotiatedCapability(node, "config.reward-files.v1", rewardFilesAccepted); boolean inspectionsWereAccepted = inspectionsAccepted; inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted); + deploymentsAccepted = deployments != null + && negotiatedCapability(node, PluginDeploymentService.CAPABILITY, deploymentsAccepted); if (inspectionsAccepted && !inspectionsWereAccepted) { inspectionFailures = 0; inspectionRetryAtNanos = 0; @@ -445,6 +688,7 @@ private JsonObject register() throws Exception { voteSitesSyncAccepted = false; rewardFilesAccepted = false; inspectionsAccepted = false; + deploymentsAccepted = false; JsonObject body = sessionBody(); body.addProperty("nodeId", settings.nodeId()); body.addProperty("displayName", settings.nodeId()); @@ -455,13 +699,13 @@ private JsonObject register() throws Exception { .map(installed -> installed.getDescription().getName()).filter(name -> name != null && !name.isBlank()) .distinct().sorted(String.CASE_INSENSITIVE_ORDER).limit(128).forEach(detectedPlugins::add); body.add("detectedPlugins", detectedPlugins); - addCapabilities(body, configurations.supportsNamedRewardFiles()); + addCapabilities(body, configurations.supportsNamedRewardFiles(), deployments != null); return requireObject(send("POST", "/api/v1/nodes/register", body), 200, 201); } private JsonObject heartbeat() throws Exception { JsonObject body = sessionBody(); - addCapabilities(body, configurations.supportsNamedRewardFiles()); + addCapabilities(body, configurations.supportsNamedRewardFiles(), deployments != null); Response response = send("PUT", "/api/v1/nodes/" + settings.nodeId() + "/heartbeat", body); if (response.status() == 404) { registered = false; @@ -711,8 +955,9 @@ private TaskResult execute(UUID operationId, JsonObject task) { } catch (BackendConfigurationService.StaleRevisionException e) { return TaskResult.failure("STALE_REVISION", "Configuration changed after preview"); } catch (BackendConfigurationService.ApplyFailureException e) { - logConfigurationFailure("reload", e); - return TaskResult.failure("RELOAD_FAILED", reloadFailureMessage(e), e.rolledBack()); + logConfigurationFailure(e.reloadAttempted() ? "reload" : "write", e); + return TaskResult.failure(e.reloadAttempted() ? "RELOAD_FAILED" : "WRITE_FAILED", + applyFailureMessage(e), e.rolledBack()); } catch (IllegalArgumentException e) { return TaskResult.failure("VALIDATION_ERROR", e.getMessage()); } catch (Exception e) { @@ -732,16 +977,41 @@ private void logConfigurationFailure(String action, Throwable failure) { } static String operationFailureMessage(String type, Throwable failure) { - if ("READ".equals(type) && failure instanceof IOException) { + if ("READ".equals(type)) { + if (hasCause(failure, java.nio.file.NoSuchFileException.class)) { + return "The managed configuration file does not exist on this backend"; + } + if (hasCause(failure, java.nio.file.AccessDeniedException.class) + || hasCause(failure, SecurityException.class)) { + return "The managed configuration file is not readable by the backend"; + } + BackendConfigurationService.ConfigurationReadException readFailure = cause( + failure, BackendConfigurationService.ConfigurationReadException.class); + if (readFailure != null) return switch (readFailure.failure()) { + case TOO_LARGE -> "The managed configuration file exceeds the 512 KiB limit"; + case INVALID_ENCODING -> "The managed configuration file is not valid UTF-8"; + case UNSAFE -> "The managed configuration path is not a safe regular file"; + }; return "Configuration file is unavailable or unreadable"; } - if ("READ".equals(type)) return "Configuration read failed; see the backend log"; - if ("PREVIEW".equals(type)) return "Configuration preview failed; see the backend log"; - return "Configuration apply failed; see the backend log"; + if ("PREVIEW".equals(type)) return "The backend could not prepare a configuration preview"; + return "The backend could not apply the managed configuration"; } - static String reloadFailureMessage(Throwable ignored) { - return "Configuration reload failed; see the backend log"; + static String applyFailureMessage(BackendConfigurationService.ApplyFailureException failure) { + if (!failure.reloadAttempted()) { + return failure.rolledBack() ? "Configuration write failed; the previous file was restored" + : "Configuration write failed before runtime reload"; + } + String reason = hasCause(failure, java.util.concurrent.TimeoutException.class) + || hasCause(failure, java.net.SocketTimeoutException.class) + ? "the configured transport did not become ready before its deadline" + : hasCause(failure, java.net.ConnectException.class) + ? "the configured transport endpoint was unavailable" + : "the backend runtime rejected the new configuration"; + return failure.rolledBack() + ? "Runtime reload failed because " + reason + "; the previous file was restored" + : "Runtime reload failed because " + reason + "; automatic rollback did not complete"; } static String operationFailureCode(String type) { @@ -749,11 +1019,34 @@ static String operationFailureCode(String type) { } static String operationFailureCode(String type, Throwable failure) { + if ("READ".equals(type) && hasCause(failure, java.nio.file.NoSuchFileException.class)) { + return "CONFIGURATION_MISSING"; + } + if ("READ".equals(type) && (hasCause(failure, java.nio.file.AccessDeniedException.class) + || hasCause(failure, SecurityException.class))) return "CONFIGURATION_UNREADABLE"; + BackendConfigurationService.ConfigurationReadException readFailure = cause( + failure, BackendConfigurationService.ConfigurationReadException.class); + if ("READ".equals(type) && readFailure != null) return switch (readFailure.failure()) { + case TOO_LARGE -> "CONFIGURATION_TOO_LARGE"; + case INVALID_ENCODING -> "CONFIGURATION_INVALID_ENCODING"; + case UNSAFE -> "CONFIGURATION_UNSAFE"; + }; if ("READ".equals(type)) return "READ_FAILED"; if ("PREVIEW".equals(type)) return "PREVIEW_FAILED"; return "APPLY_FAILED"; } + private static boolean hasCause(Throwable failure, Class type) { + return cause(failure, type) != null; + } + + private static T cause(Throwable failure, Class type) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (type.isInstance(current)) return type.cast(current); + } + return null; + } + private TaskResult executeFile(UUID operationId, String type, JsonObject configuration, JsonObject task) throws IOException { String fileName = string(configuration, "fileName"); @@ -788,6 +1081,11 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config throws IOException { String preset = string(configuration, "preset"); Map options = options(configuration.getAsJsonObject("options")); + if ("proxy-method".equals(preset) + && !proxyMethodApplyCapabilityAccepted(options.getOrDefault("method", "PLUGINMESSAGING"), + proxyMethodV2Accepted)) { + return TaskResult.failure("UNSUPPORTED_TASK", "The HTTP proxy method capability was not negotiated"); + } if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, voteSitesSyncAccepted, options)) { return TaskResult.failure("UNSUPPORTED_TASK", "The required quick setup capability was not negotiated"); @@ -853,6 +1151,10 @@ static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAc voteSitesSyncAccepted); } + static boolean proxyMethodApplyCapabilityAccepted(String method, boolean proxyMethodV2Accepted) { + return !"HTTP".equalsIgnoreCase(method) || proxyMethodV2Accepted; + } + private Response send(String method, String path, JsonObject body) throws Exception { HttpRequest request = HttpRequest.newBuilder(settings.endpoint().resolve(path)) .timeout(Duration.ofMillis(settings.requestTimeoutMillis())).header("Content-Type", "application/json") @@ -880,10 +1182,15 @@ private JsonObject sessionBody() { return body; } - static void addCapabilities(JsonObject body, boolean rewardFilesSupported) { + static void addCapabilities(JsonObject body) { + addCapabilities(body, false, false); + } + + static void addCapabilities(JsonObject body, boolean rewardFilesSupported, boolean deploymentReady) { JsonArray capabilities = new JsonArray(); CAPABILITIES.stream().sorted().filter(capability -> rewardFilesSupported || !"config.reward-files.v1".equals(capability)).forEach(capabilities::add); + if (deploymentReady) capabilities.add(PluginDeploymentService.CAPABILITY); body.add("capabilities", capabilities); JsonArray required = new JsonArray(); required.add("config.files.v1"); @@ -961,6 +1268,9 @@ public void close() { if (polling != null) polling.cancel(false); ScheduledFuture inspection = inspectionPolling; if (inspection != null) inspection.cancel(false); + ScheduledFuture deployment = deploymentPolling; + if (deployment != null) deployment.cancel(false); + if (deployments != null) deployments.cancel(); inspectionExecutor.shutdownNow(); if (reload != null && Bukkit.isPrimaryThread()) reload.cancel(false); awaitShutdown(executor, operation); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java new file mode 100644 index 000000000..2b5256738 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/PluginDeploymentService.java @@ -0,0 +1,494 @@ +package com.bencodez.votingplugin.control; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.charset.CodingErrorAction; +import java.nio.channels.FileChannel; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.time.Duration; +import java.util.HexFormat; +import java.util.Locale; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import com.bencodez.votingplugin.util.DurableFiles; + +/** + * Bounded, pull-only staging for the optional {@code plugin.deploy.v1} capability. + * It deliberately never reloads a plugin or starts a server. The small on-disk + * marker makes a leased task idempotent after a lost result acknowledgement. + */ +public final class PluginDeploymentService { + public static final String CAPABILITY = "plugin.deploy.v1"; + public static final long MAX_ARTIFACT_BYTES = 64L * 1024L * 1024L; + private static final int BUFFER_BYTES = 32 * 1024; + private static final int MAX_JAR_ENTRIES = 10_000; + private static final long MAX_INSPECTED_UNCOMPRESSED_BYTES = 256L * 1024L * 1024L; + private static final int MAX_PLUGIN_YML_BYTES = 64 * 1024; + private static final String MARKER = ".control-deployment"; + private static final ScheduledExecutorService BODY_DEADLINE_EXECUTOR = Executors.newSingleThreadScheduledExecutor(r -> { + Thread thread = new Thread(r, "VotingPlugin-control-artifact-deadline"); + thread.setDaemon(true); + return thread; + }); + + private final Path target; + private final Path root; + private final Path marker; + private final Path installedBackendJar; + private final boolean replaceExisting; + private final AtomicBoolean staging = new AtomicBoolean(); + private final AtomicReference activeResponse = new AtomicReference<>(); + + private PluginDeploymentService(Path target, Path installedBackendJar, boolean replaceExisting) throws IOException { + this.target = target.toAbsolutePath().normalize(); + this.installedBackendJar = installedBackendJar == null ? null : installedBackendJar.toAbsolutePath().normalize(); + this.replaceExisting = replaceExisting; + Path parent = this.target.getParent(); + if (parent == null) throw new IOException("deployment target has no parent"); + Files.createDirectories(parent); + this.root = parent.toRealPath(LinkOption.NOFOLLOW_LINKS); + if (!Files.isDirectory(root, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(root)) { + throw new IOException("deployment staging directory is unsafe"); + } + if (!this.target.startsWith(root) || Files.isSymbolicLink(this.target)) { + throw new IOException("deployment target escapes staging directory"); + } + if (Files.exists(this.target, LinkOption.NOFOLLOW_LINKS) + && !Files.isRegularFile(this.target, LinkOption.NOFOLLOW_LINKS)) { + throw new IOException("deployment target is not a regular file"); + } + this.marker = root.resolve(this.target.getFileName() + MARKER); + } + + /** Bukkit consumes an update only when its file name matches the currently loaded plugin JAR. */ + public static PluginDeploymentService backend(Path updateDirectory, Path currentPluginJar) throws IOException { + if (updateDirectory == null) throw new IOException("Bukkit update folder is unavailable"); + if (currentPluginJar == null || currentPluginJar.getFileName() == null + || !currentPluginJar.getFileName().toString().toLowerCase(Locale.ROOT).endsWith(".jar")) { + throw new IOException("Bukkit plugin JAR is unavailable"); + } + Path target = updateDirectory.resolve(currentPluginJar.getFileName().toString()).toAbsolutePath().normalize(); + Path installed = currentPluginJar.toAbsolutePath().normalize(); + // An empty/disabled Bukkit update folder can resolve to the loaded JAR's + // directory. Staging there would overwrite a file that Bukkit currently has open. + if (target.equals(installed) || Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && Files.exists(installed, LinkOption.NOFOLLOW_LINKS) && Files.isSameFile(target, installed)) { + throw new IOException("Bukkit update folder resolves to the loaded plugin JAR"); + } + return new PluginDeploymentService(target, currentPluginJar, false); + } + + /** Proxies atomically replace their discovered plugin JAR only after a durable backup. */ + public static PluginDeploymentService proxy(Path currentPluginJar) throws IOException { + if (currentPluginJar == null || !currentPluginJar.getFileName().toString().endsWith(".jar")) { + throw new IOException("proxy plugin JAR is unavailable"); + } + if (!Files.isRegularFile(currentPluginJar, LinkOption.NOFOLLOW_LINKS) + || Files.isSymbolicLink(currentPluginJar)) throw new IOException("proxy plugin JAR is unsafe"); + return new PluginDeploymentService(currentPluginJar, null, true); + } + + public boolean isStaging() { return staging.get(); } + + /** Unblocks an in-progress response read during connector shutdown. */ + public void cancel() { + InputStream response = activeResponse.getAndSet(null); + if (response != null) { + try { response.close(); } catch (IOException ignored) { /* Shutdown is already in progress. */ } + } + } + + public Result deploy(Task task, URI endpoint, boolean directLocalHosted, String nodeId, UUID sessionId, + String credential, HttpClient http, Duration timeout, BooleanSupplier active) { + if (!staging.compareAndSet(false, true)) return Result.failure("DEPLOYMENT_FAILED", "Another deployment is still staging"); + try { + validate(task); + if (!credentialEndpointAllowed(endpoint, directLocalHosted)) { + return Result.failure("INSECURE_ENDPOINT", + "Verified update staging requires HTTPS unless Control is hosted directly on this node"); + } + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before download"); + if (alreadyStaged(task)) return Result.restartRequired(); + URI artifact = endpoint.resolve("/api/v1/nodes/" + nodeId + "/deployments/" + task.deploymentId() + + "/artifact"); + HttpRequest request = HttpRequest.newBuilder(artifact).timeout(timeout) + .header("Authorization", "Bearer " + credential) + .header("X-Node-Session", sessionId.toString()) + .header("X-Deployment-Attempt", task.attemptId().toString()).GET().build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofInputStream()); + InputStream body = response.body(); + activeResponse.set(body); + ScheduledFuture bodyDeadline = BODY_DEADLINE_EXECUTOR.schedule(() -> { + try { body.close(); } catch (IOException ignored) { /* The staging operation reports the timeout. */ } + }, timeout.toMillis(), TimeUnit.MILLISECONDS); + try { + if (response.statusCode() != 200) { + body.close(); + return Result.failure("DOWNLOAD_FAILED", "Artifact download was rejected"); + } + String contentLength = response.headers().firstValue("Content-Length").orElse(null); + if (contentLength != null && (!contentLength.matches("[0-9]{1,9}") + || Long.parseLong(contentLength) != task.size())) { + body.close(); + return Result.failure("SIZE_MISMATCH", "Artifact size did not match the deployment task"); + } + if (!active.getAsBoolean()) { + body.close(); + return Result.failure("CANCELLED", "Deployment was cancelled before download"); + } + try (body) { + return stage(task, body, active); + } catch (IOException failure) { + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + return Result.failure("STAGING_FAILED", "Artifact could not be verified or staged on this node"); + } + } finally { + bodyDeadline.cancel(false); + activeResponse.compareAndSet(body, null); + } + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + return Result.failure("CANCELLED", "Deployment download was interrupted"); + } catch (IOException | RuntimeException failure) { + return Result.failure("DOWNLOAD_FAILED", "Artifact download failed"); + } finally { + staging.set(false); + } + } + + /** Package-visible for deterministic artifact-validation tests. */ + Result stage(Task task, InputStream body, BooleanSupplier active) throws IOException { + validate(task); + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + if (alreadyStaged(task)) return Result.restartRequired(); + activeResponse.compareAndSet(null, body); + Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".download"); + Activation activation = null; + try { + MessageDigest digest = sha256(); + long written = copyExact(body, temporary, task.size(), digest, active); + force(temporary); + if (written != task.size()) return Result.failure("SIZE_MISMATCH", "Artifact size did not match the deployment task"); + if (!HexFormat.of().formatHex(digest.digest()).equals(task.sha256())) { + return Result.failure("HASH_MISMATCH", "Artifact digest did not match the deployment task"); + } + inspectJar(temporary); + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + activation = new Activation(); + prepareProxyBackup(); + writeMarker(task, activation); + activate(temporary, activation); + activation.discard(); + return Result.restartRequired(); + } catch (CancelledDeploymentException failure) { + return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + } catch (InvalidArtifactException failure) { + return Result.failure("INVALID_ARTIFACT", "Artifact is not a bounded VotingPlugin JAR"); + } catch (IOException failure) { + if (activation != null) { + try { activation.rollback(); } + catch (IOException rollbackFailure) { failure.addSuppressed(rollbackFailure); } + } + if (!active.getAsBoolean()) return Result.failure("CANCELLED", "Deployment was cancelled before staging"); + throw failure; + } finally { + activeResponse.compareAndSet(body, null); + Files.deleteIfExists(temporary); + } + } + + private long copyExact(InputStream input, Path temporary, long expected, MessageDigest digest, + BooleanSupplier active) throws IOException { + long total = 0; + byte[] buffer = new byte[BUFFER_BYTES]; + try (var output = Files.newOutputStream(temporary, StandardOpenOption.TRUNCATE_EXISTING)) { + for (;;) { + if (!active.getAsBoolean()) throw new CancelledDeploymentException(); + int read = input.read(buffer); + if (read == -1) break; + total += read; + if (total > expected || total > MAX_ARTIFACT_BYTES) throw new InvalidArtifactException(); + digest.update(buffer, 0, read); + output.write(buffer, 0, read); + } + } + return total; + } + + private void inspectJar(Path artifact) throws IOException { + try (JarFile jar = new JarFile(artifact.toFile(), false)) { + int entries = 0; + long uncompressed = 0; + JarEntry pluginYml = null; + var iterator = jar.entries(); + while (iterator.hasMoreElements()) { + JarEntry entry = iterator.nextElement(); + if (++entries > MAX_JAR_ENTRIES || unsafeEntry(entry.getName())) throw new InvalidArtifactException(); + long size = entry.getSize(); + long compressed = entry.getCompressedSize(); + if (size < 0 || compressed < 0 || compressed > 0 && size > compressed * 200L + || (uncompressed += size) > MAX_INSPECTED_UNCOMPRESSED_BYTES) { + throw new InvalidArtifactException(); + } + if ("plugin.yml".equals(entry.getName())) { + if (pluginYml != null || size > MAX_PLUGIN_YML_BYTES) throw new InvalidArtifactException(); + pluginYml = entry; + } + } + if (pluginYml == null || !isVotingPluginYml(jar, pluginYml)) throw new InvalidArtifactException(); + } catch (java.util.zip.ZipException failure) { + throw new InvalidArtifactException(); + } + } + + private static boolean unsafeEntry(String name) { + if (name == null || name.isEmpty() || name.length() > 512 || name.startsWith("/") + || name.startsWith("\\") || name.contains("\\") || name.indexOf('\0') >= 0) return true; + for (String component : name.split("/", -1)) { + if (component.equals(".") || component.equals("..")) return true; + } + return false; + } + + private static boolean isVotingPluginYml(JarFile jar, JarEntry entry) throws IOException { + byte[] bytes; + try (InputStream input = jar.getInputStream(entry)) { bytes = input.readNBytes(MAX_PLUGIN_YML_BYTES + 1); } + if (bytes.length > MAX_PLUGIN_YML_BYTES) return false; + String yml; + try { + yml = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT).decode(java.nio.ByteBuffer.wrap(bytes)).toString(); + } catch (java.nio.charset.CharacterCodingException failure) { + return false; + } + boolean found = false; + for (String line : yml.split("\\R")) { + if (!line.startsWith("name:")) continue; + if (found) return false; + found = true; + String value = line.substring(5).trim(); + int comment = value.indexOf('#'); + if (comment >= 0) value = value.substring(0, comment).trim(); + if (!"VotingPlugin".equals(unquote(value))) return false; + } + return found; + } + + private static String unquote(String value) { + return value.length() >= 2 && ((value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'"))) ? value.substring(1, value.length() - 1) : value; + } + + private void prepareProxyBackup() throws IOException { + if (replaceExisting) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IOException("current proxy plugin JAR is unsafe"); + } + Path backup = root.resolve(target.getFileName() + ".control-backup"); + if (Files.exists(backup, LinkOption.NOFOLLOW_LINKS) + && (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(backup))) { + throw new IOException("proxy plugin backup is unsafe"); + } + Path backupTemp = Files.createTempFile(root, target.getFileName().toString() + ".", ".backup"); + try { + Files.copy(target, backupTemp, StandardCopyOption.REPLACE_EXISTING); + force(backupTemp); + move(backupTemp, backup); + forceDirectory(root); + } finally { Files.deleteIfExists(backupTemp); } + } + } + + private void activate(Path temporary, Activation activation) throws IOException { + move(temporary, target); + activation.published = true; + force(target); + forceDirectory(root); + } + + /** Holds a private copy until the durable idempotency marker has been published. */ + private final class Activation { + private final Path previous; + private boolean published; + private boolean markerPublished; + + private Activation() throws IOException { + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + previous = null; + return; + } + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IOException("deployment target is unsafe"); + } + previous = Files.createTempFile(root, target.getFileName().toString() + ".", ".rollback"); + Files.copy(target, previous, StandardCopyOption.REPLACE_EXISTING); + force(previous); + } + + private void rollback() throws IOException { + if (markerPublished) DurableFiles.deleteIfExists(marker); + if (!published) { + discard(); + return; + } + if (previous == null) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) { + throw new IOException("deployed target cannot be safely removed"); + } + Files.delete(target); + } else { + move(previous, target); + force(target); + } + forceDirectory(root); + discard(); + } + + private void discard() { + if (previous == null) return; + try { Files.deleteIfExists(previous); } + catch (IOException ignored) { /* A private stale rollback copy is safer than a false deployment result. */ } + } + } + + private void writeMarker(Task task) throws IOException { + writeMarker(task, null); + } + + private void writeMarker(Task task, Activation activation) throws IOException { + Path temporary = Files.createTempFile(root, target.getFileName().toString() + ".", ".marker"); + try { + Files.writeString(temporary, task.deploymentId() + "\n" + task.sha256() + "\n" + task.size() + "\n", + StandardCharsets.US_ASCII, StandardOpenOption.TRUNCATE_EXISTING); + force(temporary); + move(temporary, marker); + if (activation != null) activation.markerPublished = true; + forceDirectory(root); + } finally { Files.deleteIfExists(temporary); } + } + + private boolean alreadyStaged(Task task) throws IOException { + if (!Files.isRegularFile(marker, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(marker) + || Files.size(marker) > 256) return false; + if (replaceExisting) { + Path backup = root.resolve(target.getFileName() + ".control-backup"); + if (!Files.isRegularFile(backup, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(backup)) return false; + } + String[] fields = Files.readString(marker, StandardCharsets.US_ASCII).split("\\R", -1); + // A lost acknowledgement can cause Control to issue a new deployment ID + // for the same verified artifact after reconnect or restart. The marker + // only proves a completed prior stage; the target is checked below by + // exact size, digest and plugin identity before acknowledging the retry. + if (fields.length < 3 || !task.sha256().equals(fields[1]) + || !Long.toString(task.size()).equals(fields[2])) return false; + if (!replaceExisting && !Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + // Bukkit removes the staged update JAR after consuming it on restart. A + // missing staging file alone is not proof: it may have been deleted or + // quarantined. Confirm the installed backend JAR is the exact artifact. + return installedBackendJar != null && fileMatches(installedBackendJar, task); + } + return targetMatches(task); + } + + private boolean targetMatches(Task task) throws IOException { + return fileMatches(target, task); + } + + private boolean fileMatches(Path candidate, Task task) throws IOException { + if (!Files.isRegularFile(candidate, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(candidate) + || Files.size(candidate) != task.size()) return false; + MessageDigest digest = sha256(); + try (InputStream input = Files.newInputStream(candidate, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + byte[] bytes = new byte[BUFFER_BYTES]; + for (int read; (read = input.read(bytes)) != -1;) digest.update(bytes, 0, read); + } + if (!task.sha256().equals(HexFormat.of().formatHex(digest.digest()))) return false; + try { + inspectJar(candidate); + return true; + } catch (InvalidArtifactException invalid) { + return false; + } + } + + static void move(Path source, Path destination) throws IOException { + // The proxy target is executable on its next startup. A copy/delete fallback + // can leave it missing or partial after a crash, even with a durable backup. + Files.move(source, destination, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } + + private static void force(Path file) throws IOException { + try (FileChannel channel = FileChannel.open(file, StandardOpenOption.WRITE)) { channel.force(true); } + } + + private static void forceDirectory(Path directory) throws IOException { + DurableFiles.forceDirectory(directory); + } + + /** True when a deployment bearer credential may be sent to this Control endpoint. */ + public static boolean credentialEndpointAllowed(URI endpoint, boolean directLocalHosted) { + if (endpoint == null) return false; + if ("https".equalsIgnoreCase(endpoint.getScheme())) return true; + return directLocalHosted && "http".equalsIgnoreCase(endpoint.getScheme()) + && isLoopbackHost(endpoint.getHost()); + } + + private static boolean isLoopbackHost(String host) { + if (host == null) return false; + String normalized = host; + if (normalized.length() >= 2 && normalized.charAt(0) == '[' + && normalized.charAt(normalized.length() - 1) == ']') { + normalized = normalized.substring(1, normalized.length() - 1); + } + if ("localhost".equalsIgnoreCase(normalized) || "::1".equalsIgnoreCase(normalized) + || "0:0:0:0:0:0:0:1".equalsIgnoreCase(normalized)) return true; + String[] octets = normalized.split("\\.", -1); + if (octets.length != 4 || !"127".equals(octets[0])) return false; + for (int index = 1; index < octets.length; index++) { + if (!octets[index].matches("[0-9]{1,3}") || Integer.parseInt(octets[index]) > 255) return false; + } + return true; + } + + private static MessageDigest sha256() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (java.security.NoSuchAlgorithmException failure) { throw new IllegalStateException("SHA-256 is unavailable", failure); } + } + + private static void validate(Task task) { + if (task == null || task.deploymentId() == null || task.attemptId() == null || task.artifactId() == null + || !task.artifactId().matches("[A-Za-z0-9][A-Za-z0-9._-]{0,127}") || task.sha256() == null + || !task.sha256().matches("[0-9a-fA-F]{64}") || task.size() < 1 || task.size() > MAX_ARTIFACT_BYTES) { + throw new IllegalArgumentException("deployment task is invalid"); + } + } + + public record Task(UUID deploymentId, String artifactId, String sha256, long size, UUID attemptId) { + public Task { if (sha256 != null) sha256 = sha256.toLowerCase(Locale.ROOT); } + } + public record Result(boolean success, String code, String message) { + static Result restartRequired() { return new Result(true, "RESTART_REQUIRED", "Plugin update staged; restart is required"); } + static Result failure(String code, String message) { return new Result(false, code, message); } + } + private static final class InvalidArtifactException extends IOException { private static final long serialVersionUID = 1L; } + private static final class CancelledDeploymentException extends IOException { private static final long serialVersionUID = 1L; } +} 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 31979e197..5e7188a5f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -41,10 +41,11 @@ public void processVote(String voteSite, String voteUsername) { if (plugin.getBungeeSettings().isUseBungeecoord() && !plugin.getBungeeSettings().isVotifierBypass() && (plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.PLUGINMESSAGING) || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.SOCKETS) + || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.HTTP) || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.MQTT) || plugin.getBackendProxyHandler().getMethod().equals(BungeeMethod.REDIS))) { plugin.getLogger().severe( - "Ignoring vote from votifier since pluginmessaging, socket, redis, or mqtt bungee method is enabled, this means you aren't setup correctly for those methods, please check: https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups"); + "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; } @@ -123,7 +124,7 @@ public void onVotiferEvent(VotifierEvent event) { if (!MinecraftUsernameValidator.isValid(voteUsername, plugin.getOptions().getBedrockPlayerPrefix())) { plugin.getLogger().warning("Rejected vote with invalid Minecraft username '" + MinecraftUsernameValidator.sanitizeForLog(voteUsername) + "' from service '" - + MinecraftUsernameValidator.sanitizeForLog(voteSite) + "'"); + + ServiceSiteValidator.sanitizeForLog(voteSite) + "'"); return; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java index c2cb5d9c8..2142037e8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/BungeeMethod.java @@ -8,9 +8,11 @@ public enum BungeeMethod { MYSQL, /** Plugin messaging channel. */ PLUGINMESSAGING, - /** Socket connection. */ - SOCKETS, - /** Redis connection. */ + /** Socket connection. */ + SOCKETS, + /** Encrypted single-port HTTP connector. */ + HTTP, + /** Redis connection. */ REDIS, /** MQTT message broker. */ MQTT; 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 329681705..cb662eba2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/OfflineBungeeVote.java @@ -1,9 +1,14 @@ package com.bencodez.votingplugin.proxy; -import java.util.Collections; -import java.util.LinkedHashSet; -import java.util.Set; -import java.util.UUID; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Locale; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; @@ -46,6 +51,26 @@ public class OfflineBungeeVote { @Getter @Setter private boolean deliveryStateDirty; + /** SQL primary key for this vote's server-keyed cache row, when applicable. */ + @Getter + @Setter + private int serverVoteCacheRowId; + /** JSON entry key for this vote's server-keyed emergency cache row, when applicable. */ + @Getter + @Setter + private String serverVoteCacheJsonKey; + /** SQL primary key for this vote's voter-keyed cache row, when applicable. */ + @Getter + @Setter + private int onlineVoteCacheRowId; + /** JSON entry key for this vote's voter-keyed emergency cache row, when applicable. */ + @Getter + @Setter + private String onlineVoteCacheJsonKey; + /** Stable HTTP delivery IDs that must be reused for each target server. */ + private final Map httpDeliveryIds; + /** Stable HTTP standalone-broadcast IDs, separate from reward delivery IDs. */ + private final Map httpBroadcastDeliveryIds; /** * Constructor with UUID voteId. @@ -94,10 +119,29 @@ public OfflineBungeeVote(UUID voteId, String playerName, String uuid, String ser * @param broadcastForwardedServers targets that accepted standalone delivery * @param rewardDelivered whether the cached reward vote was already delivered */ - public OfflineBungeeVote(UUID voteId, String playerName, String uuid, String service, long time, boolean realVote, - String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, - Set broadcastForwardedServers, boolean rewardDelivered) { - this.playerName = playerName; + public OfflineBungeeVote(UUID voteId, String playerName, String uuid, String service, long time, boolean realVote, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, + Set broadcastForwardedServers, boolean rewardDelivered) { + this(voteId, playerName, uuid, service, time, realVote, text, broadcastForwarded, proxyBroadcastHandled, + broadcastTargets, broadcastForwardedServers, rewardDelivered, Collections.emptyMap()); + } + + /** + * Constructor with full proxy state and stable HTTP delivery IDs. + * @param httpDeliveryIds stable HTTP delivery ID by target server + */ + public OfflineBungeeVote(UUID voteId, String playerName, String uuid, String service, long time, boolean realVote, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, + Set broadcastForwardedServers, boolean rewardDelivered, Map httpDeliveryIds) { + this(voteId, playerName, uuid, service, time, realVote, text, broadcastForwarded, proxyBroadcastHandled, + broadcastTargets, broadcastForwardedServers, rewardDelivered, httpDeliveryIds, Collections.emptyMap()); + } + + public OfflineBungeeVote(UUID voteId, String playerName, String uuid, String service, long time, boolean realVote, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, + Set broadcastForwardedServers, boolean rewardDelivered, Map httpDeliveryIds, + Map httpBroadcastDeliveryIds) { + this.playerName = playerName; this.uuid = uuid; this.service = service; this.time = time; @@ -106,10 +150,16 @@ public OfflineBungeeVote(UUID voteId, String playerName, String uuid, String ser this.voteId = voteId; this.broadcastForwarded = broadcastForwarded; this.proxyBroadcastHandled = proxyBroadcastHandled; - setBroadcastTargets(broadcastTargets); - setBroadcastForwardedServers(broadcastForwardedServers); - this.rewardDelivered = rewardDelivered; - } + setBroadcastTargets(broadcastTargets); + setBroadcastForwardedServers(broadcastForwardedServers); + this.rewardDelivered = rewardDelivered; + this.httpDeliveryIds = new LinkedHashMap<>(); + if (httpDeliveryIds != null) { + httpDeliveryIds.forEach(this::setHttpDeliveryId); + } + this.httpBroadcastDeliveryIds = new LinkedHashMap<>(); + if (httpBroadcastDeliveryIds != null) httpBroadcastDeliveryIds.forEach(this::setHttpBroadcastDeliveryId); + } /** * Constructor with String voteId. @@ -157,12 +207,28 @@ public OfflineBungeeVote(String voteId, String playerName, String uuid, String s * @param broadcastForwardedServers targets that accepted standalone delivery * @param rewardDelivered whether the cached reward vote was already delivered */ - public OfflineBungeeVote(String voteId, String playerName, String uuid, String service, long time, boolean realVote, - String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, - Set broadcastForwardedServers, boolean rewardDelivered) { - this(parseVoteId(voteId), playerName, uuid, service, time, realVote, text, broadcastForwarded, - proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered); - } + public OfflineBungeeVote(String voteId, String playerName, String uuid, String service, long time, boolean realVote, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, + Set broadcastForwardedServers, boolean rewardDelivered) { + this(parseVoteId(voteId), playerName, uuid, service, time, realVote, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered); + } + + public OfflineBungeeVote(String voteId, String playerName, String uuid, String service, long time, boolean realVote, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, + Set broadcastForwardedServers, boolean rewardDelivered, Map httpDeliveryIds) { + this(parseVoteId(voteId), playerName, uuid, service, time, realVote, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered, httpDeliveryIds); + } + + public OfflineBungeeVote(String voteId, String playerName, String uuid, String service, long time, boolean realVote, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, Set broadcastTargets, + Set broadcastForwardedServers, boolean rewardDelivered, Map httpDeliveryIds, + Map httpBroadcastDeliveryIds) { + this(parseVoteId(voteId), playerName, uuid, service, time, realVote, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered, httpDeliveryIds, + httpBroadcastDeliveryIds); + } private static UUID parseVoteId(String voteId) { return voteId == null || voteId.isEmpty() ? null : UUID.fromString(voteId); @@ -198,12 +264,118 @@ public boolean isProxyBroadcastComplete() { * @param server backend server receiving the cached vote * @return true when this server is an original undelivered target */ - public boolean needsBroadcastOn(String server) { + public boolean needsBroadcastOn(String server) { if (!proxyBroadcastHandled) { return !broadcastForwarded; } return server != null && broadcastTargets.contains(server) && !broadcastForwardedServers.contains(server); - } + } + + /** + * Returns the stable HTTP delivery ID for a target, if one is pending. + * @param server target server + * @return stable delivery ID or null + */ + public String getHttpDeliveryId(String server) { + return server == null ? null : httpDeliveryIds.get(server.toLowerCase(Locale.ROOT)); + } + + /** Returns a copy of stable reward delivery IDs by normalized target key. */ + public Map getHttpDeliveryIds() { + return new LinkedHashMap<>(httpDeliveryIds); + } + + /** + * Stores or removes a stable HTTP delivery ID for a target. + * @param server target server + * @param deliveryId stable delivery ID, or null to remove + */ + public void setHttpDeliveryId(String server, String deliveryId) { + if (server == null || server.isBlank()) return; + String key = server.toLowerCase(Locale.ROOT); + if (deliveryId == null || deliveryId.isBlank()) httpDeliveryIds.remove(key); + else httpDeliveryIds.put(key, deliveryId); + } + + public String getHttpBroadcastDeliveryId(String server) { + return server == null ? null : httpBroadcastDeliveryIds.get(server.toLowerCase(Locale.ROOT)); + } + + public void setHttpBroadcastDeliveryId(String server, String deliveryId) { + if (server == null || server.isBlank()) return; + String key = server.toLowerCase(Locale.ROOT); + if (deliveryId == null || deliveryId.isBlank()) httpBroadcastDeliveryIds.remove(key); + else httpBroadcastDeliveryIds.put(key, deliveryId); + } + + /** + * Returns a copy of pending standalone HTTP delivery IDs for cache handoff. + * @return pending standalone delivery IDs by target server + */ + public Map getHttpBroadcastDeliveryIds() { + return new LinkedHashMap<>(httpBroadcastDeliveryIds); + } + + /** Returns whether any reward or standalone HTTP delivery is still pending. */ + public boolean hasPendingHttpDeliveryIds() { + return !httpDeliveryIds.isEmpty() || !httpBroadcastDeliveryIds.isEmpty(); + } + + /** + * Encodes stable delivery IDs for JSON/SQL cache storage. + * @return bounded delimiter-safe encoding + */ + public String encodeHttpDeliveryIds() { + return encodeDeliveryIds(httpDeliveryIds); + } + + public String encodeHttpBroadcastDeliveryIds() { + return encodeDeliveryIds(httpBroadcastDeliveryIds); + } + + private static String encodeDeliveryIds(Map values) { + StringBuilder encoded = new StringBuilder(); + for (Map.Entry entry : values.entrySet()) { + if (encoded.length() > 0) encoded.append('.'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8))); + encoded.append('~'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getValue().getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + /** + * Decodes stable delivery IDs from cache storage. Malformed entries are ignored. + * @param encoded encoded map + * @return decoded map + */ + public static Map decodeHttpDeliveryIds(String encoded) { + return decodeDeliveryIds(encoded); + } + + public static Map decodeHttpBroadcastDeliveryIds(String encoded) { + return decodeDeliveryIds(encoded); + } + + private static Map decodeDeliveryIds(String encoded) { + Map decoded = new LinkedHashMap<>(); + if (encoded == null || encoded.isBlank()) return decoded; + for (String entry : encoded.split("\\.", -1)) { + int separator = entry.indexOf('~'); + if (separator <= 0 || separator == entry.length() - 1) continue; + try { + String server = new String(Base64.getUrlDecoder().decode(entry.substring(0, separator)), StandardCharsets.UTF_8); + String deliveryId = new String(Base64.getUrlDecoder().decode(entry.substring(separator + 1)), StandardCharsets.UTF_8); + if (!server.isBlank() && deliveryId.matches("[0-9a-fA-F-]{36}")) + decoded.put(server.toLowerCase(Locale.ROOT), deliveryId); + } catch (IllegalArgumentException ignored) { + // Ignore corrupt optional delivery state and retain the vote itself. + } + } + return decoded; + } /** * Encodes the original broadcast targets for cache storage. 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 841ed2eba..be7053e6b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxy.java @@ -11,6 +11,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Base64; import java.sql.SQLException; import java.time.Duration; import java.time.Instant; @@ -21,9 +26,12 @@ import java.util.Collection; import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Queue; import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; @@ -34,6 +42,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; import javax.net.ssl.SSLParameters; @@ -49,6 +58,9 @@ import com.bencodez.simpleapi.servercomm.codec.JsonEnvelopeCodec; import com.bencodez.simpleapi.servercomm.global.GlobalMessageListener; import com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler; +import com.bencodez.simpleapi.servercomm.http.HttpEnrollmentAuthority; +import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; +import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity; import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler; import com.bencodez.simpleapi.servercomm.mqtt.MqttServerComm; import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; @@ -66,6 +78,7 @@ import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; import com.bencodez.votingplugin.proxy.broadcast.ProxyBroadcastDecider; import com.bencodez.votingplugin.proxy.cache.IVoteCache; +import com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects; import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; import com.bencodez.votingplugin.proxy.cache.nonvoted.INonVotedPlayersStorage; import com.bencodez.votingplugin.proxy.cache.nonvoted.NonVotedPlayersCache; @@ -79,6 +92,7 @@ import com.bencodez.votingplugin.proxy.presence.PlayerPresence; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; import com.bencodez.votingplugin.topvoter.TopVoter; +import com.bencodez.votingplugin.util.DurableFiles; import com.bencodez.votingplugin.util.MinecraftUsernameValidator; import com.bencodez.votingplugin.util.ServiceSiteValidator; import com.bencodez.votingplugin.votelog.VoteLogMysqlTable; @@ -95,11 +109,102 @@ import lombok.Setter; public abstract class VotingPluginProxy { + public static final class VoteRetryException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private VoteRetryException() { + super("Vote processing could not be made durable; retry is required"); + } + } private static final long PRESENCE_HANDOFF_TIMEOUT_MILLIS = TimeUnit.MINUTES.toMillis(2); private static final long PRESENCE_STARTUP_RESYNC_DELAY_SECONDS = 5L; + private static final int MAX_LIVE_VOTE_RETRIES = 1024; + private static final int MAX_MULTI_PROXY_VOTE_ATTEMPTS = 12; + private static final int MAX_MULTI_PROXY_VOTE_RETRIES = 1024; + private static final int MAX_COMPLETED_MULTI_PROXY_VOTES = 4096; + private static final String FORWARDED_QUEUE_TOTALS_PREFIX = "\u0000VP-FWD:"; + private static final int FINAL_SHUTDOWN_PERSISTENCE_ATTEMPTS = 3; + private static final String REWARD_JOURNAL_TARGET_PREFIX = "__vp_reward_target__:"; + private final Map liveVoteRetries = new LinkedHashMap<>(); + private final Map multiProxyVoteRetries = new LinkedHashMap<>(); + private final LinkedHashMap completedMultiProxyVotes = new LinkedHashMap<>(); + // Set only after all replacement gates have succeeded. Vote entry points are + // synchronized, so no new side-effecting vote can race the handoff window. + private boolean runtimeReplacementPrepared; + + private static final class LiveVoteRetryState { + private String requestIdentity; + private VoteTotalsSnapshot totals; + private ArrayList totalsInput; + private boolean votePartyApplied; + private boolean totalsApplied; + private final Set broadcastForwardedServers = new LinkedHashSet<>(); + private final Set deliveredRewardServers = new LinkedHashSet<>(); + private final Map rewardStates = new LinkedHashMap<>(); + private Set rewardServers; + private boolean rewardJournalsDurable; + private boolean multiProxyForwardingHandled; + private OfflineBungeeVote standaloneBroadcastState; + private OfflineBungeeVote rewardJournalOwner; + private OfflineBungeeVote pendingOnlineRewardState; + private VoteTimeQueue queuedVote; + private String player; + private String service; + private String uuid; + private long time; + private boolean realVote; + + } + + private final class MultiProxyVoteRetry implements Runnable { + private enum Phase { + EXECUTE, + PERSIST_DEFERRED_RECEIPT, + PERSIST_COMPLETION + } + + private final String player; + private final String service; + private final boolean realVote; + private final boolean timeQueue; + private final long queueTime; + private final VoteTotalsSnapshot totals; + private final String uuid; + private final UUID voteId; + private final String origin; + private int attempts; + private boolean scheduled; + private Phase phase = Phase.EXECUTE; + + private MultiProxyVoteRetry(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot totals, String uuid, UUID voteId, String origin) { + this.player = player; + this.service = service; + this.realVote = realVote; + this.timeQueue = timeQueue; + this.queueTime = queueTime; + this.totals = totals; + this.uuid = uuid; + this.voteId = voteId; + this.origin = origin == null ? "" : origin; + } + + @Override + public void run() { + attemptMultiProxyVote(this); + } + } private static final long PRESENCE_MAINTENANCE_INTERVAL_SECONDS = 30L; private static final long PRESENCE_BACKEND_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(90); private static final long CONTROL_ENROLLMENT_MIN_INTERVAL_NANOS = TimeUnit.SECONDS.toNanos(10); + private static final int MAX_PENDING_VOTE_PARTY_REWARDS = 1024; + private static final long HTTP_TRANSPORT_RECONCILIATION_DELAY_MILLIS = 100L; + // Acks run before SimpleAPI removes an entry. Keep one bounded, single-flight + // poll armed while a replacement is deferred so state cleared after an ack (or + // from the durable cache) cannot strand the old HTTP runtime indefinitely. + private static final long HTTP_TRANSPORT_RECONCILIATION_POLL_MILLIS = 1_000L; + private static final String HTTP_RETAINED_LISTENER_MAGIC = "VotingPlugin HTTP retained listener v1"; + private static final Map PREPARED_HTTP_TRANSPORTS = new ConcurrentHashMap<>(); @Getter @Setter @@ -118,6 +223,53 @@ public abstract class VotingPluginProxy { private HashMap clientHandles; private SocketHandler socketHandler; + private HttpProxyTransportServer httpTransportServer; + private HttpEnrollmentAuthority httpEnrollmentAuthority; + private String liveHttpHost; + private String liveHttpPublicEndpoint; + private int liveHttpPort; + private HttpListenerSettings retainedHttpStartupSettings; + + private static final class HttpListenerSettings { + private final String host; + private final int port; + private final String publicEndpoint; + + private HttpListenerSettings(String host, int port, String publicEndpoint) { + this.host = host; + this.port = port; + this.publicEndpoint = publicEndpoint; + } + } + + private static final class PreparedHttpTransport { + private final HttpProxyTransportServer server; + private final HttpEnrollmentAuthority authority; + private final AtomicReference owner; + private final String host; + private final int port; + private final String publicEndpoint; + + private PreparedHttpTransport(HttpProxyTransportServer server, HttpEnrollmentAuthority authority, + AtomicReference owner, String host, int port, String publicEndpoint) { + this.server = server; + this.authority = authority; + this.owner = owner; + this.host = host; + this.port = port; + this.publicEndpoint = publicEndpoint; + } + + private boolean matches(VotingPluginProxyConfig config) { + return java.util.Objects.equals(host, config.getHttpHost()) && port == config.getHttpPort() + && java.util.Objects.equals(publicEndpoint, config.getHttpPublicEndpoint()); + } + + private void close() { + owner.set(null); + server.close(); + } + } @Getter @Setter @@ -137,6 +289,15 @@ public abstract class VotingPluginProxy { private boolean timeVoteRetryScheduled; private boolean timeVoteDeliveryRetryScheduled; private boolean cachedVoteDeliveryRetryScheduled; + private boolean votePartyDeliveryRetryScheduled; + private boolean deferredHttpTransportReconciliation; + private boolean httpTransportReconciliationScheduled; + private boolean httpTransportReconciliationRunning; + private long httpTransportReconciliationGeneration; + private long votePartyProxyCommandAttemptSequence; + private long votePartyProxyCommandInFlight; + private volatile CompletableFuture votePartyProxyCommandExecution; + private boolean votePartyProxyCommandCompletedUnpersisted; private boolean enabled; @@ -524,16 +685,118 @@ public void addVoteParty() { private Set sendProxyBroadcast(Set targets, String uuid, String player, String service, long time, String text, boolean wasOnline) { + return sendProxyBroadcast(targets, uuid, player, service, time, text, wasOnline, + (OfflineBungeeVote) null); + } + + private Set sendProxyBroadcast(Set targets, String uuid, String player, String service, long time, + String text, boolean wasOnline, OfflineBungeeVote cachedVote) { + Set forwarded = new LinkedHashSet<>(); + for (String targetServer : targets) { + JsonEnvelope envelope = VotingPluginWire.voteBroadcast(uuid, player, service, time, text, wasOnline); + boolean accepted = method == BungeeMethod.HTTP + ? sendHttpBroadcastEnvelopeWithRecovery(targetServer, envelope, cachedVote) + : sendProxyBroadcastEnvelopeNow(targetServer, envelope); + if (accepted) { + forwarded.add(targetServer); + } + } + return forwarded; + } + + private Set sendProxyBroadcast(Set targets, String uuid, String player, String service, long time, + String text, boolean wasOnline, VoteTimeQueue cachedVote) { Set forwarded = new LinkedHashSet<>(); for (String targetServer : targets) { + // A timed vote can survive a proxy restart. Persist its HTTP delivery ID before + // publication so a crash after the transport accepts it replays with the same + // ID rather than creating a second backend broadcast. + if (method == BungeeMethod.HTTP && !prepareTimedHttpBroadcastDelivery(targetServer, cachedVote)) { + continue; + } JsonEnvelope envelope = VotingPluginWire.voteBroadcast(uuid, player, service, time, text, wasOnline); - if (sendProxyBroadcastEnvelopeNow(targetServer, envelope)) { + boolean accepted = method == BungeeMethod.HTTP + ? sendHttpBroadcastEnvelopeWithRecovery(targetServer, envelope, cachedVote) + : sendProxyBroadcastEnvelopeNow(targetServer, envelope); + if (accepted) { forwarded.add(targetServer); + if (method == BungeeMethod.HTTP) { + cachedVote.getBroadcastForwardedServers().add(targetServer); + // Persist completion before preparing another target. Otherwise persisting + // that target's ID could leave this accepted target looking pending after a + // crash, and it would be replayed under a newly generated ID. + if (!persistTimeVoteDelivery(cachedVote)) break; + } else if (cachedVote.getHttpBroadcastDeliveryId(targetServer) != null) { + cachedVote.setHttpBroadcastDeliveryId(targetServer, null); + cachedVote.setDeliveryStateDirty(true); + } } } return forwarded; } + private boolean prepareTimedHttpBroadcastDelivery(String server, VoteTimeQueue vote) { + if (vote.getHttpBroadcastDeliveryId(server) != null) return true; + vote.setHttpBroadcastDeliveryId(server, UUID.randomUUID().toString()); + vote.setDeliveryStateDirty(true); + return persistTimeVoteDelivery(vote); + } + + protected boolean sendHttpBroadcastEnvelopeWithRecovery(String server, JsonEnvelope envelope, + OfflineBungeeVote cachedVote) { + String persistedId = cachedVote == null ? null : cachedVote.getHttpBroadcastDeliveryId(server); + String stableId = persistedId != null ? persistedId + : stableCachedHttpDeliveryId("broadcast", server, envelope, cachedVote); + try { + boolean accepted = stableId == null ? sendProxyBroadcastEnvelopeNow(server, envelope) + : sendHttpEnvelope(server, stableId, envelope); + if (accepted && persistedId != null) { + cachedVote.setHttpBroadcastDeliveryId(server, null); + cachedVote.setDeliveryStateDirty(true); + } + return accepted; + } catch (HttpProxyTransportServer.DeliveryRetryException failure) { + if (cachedVote != null) { + cachedVote.setHttpBroadcastDeliveryId(server, failure.deliveryId()); + cachedVote.setDeliveryStateDirty(true); + // Let the caller persist the recovered ID before retrying. Retrying here + // would create a crash window after acceptance but before durable cache state. + return false; + } + try { + boolean accepted = sendHttpEnvelope(server, failure.deliveryId(), envelope); + return accepted; + } catch (RuntimeException retryFailure) { + debug("Unable to recover HTTP standalone delivery " + failure.deliveryId() + ": " + + retryFailure.getMessage()); + return false; + } + } catch (RuntimeException failure) { + debug("Unable to send HTTP standalone delivery: " + failure.getMessage()); + return false; + } + } + + private boolean sendHttpBroadcastEnvelopeWithRecovery(String server, JsonEnvelope envelope, + VoteTimeQueue cachedVote) { + String stableId = cachedVote.getHttpBroadcastDeliveryId(server); + if (stableId == null) { + debug("Skipping HTTP timed broadcast without a persisted delivery ID for " + server); + return false; + } + try { + boolean accepted = sendHttpEnvelope(server, stableId, envelope); + if (accepted) { + cachedVote.setHttpBroadcastDeliveryId(server, null); + cachedVote.setDeliveryStateDirty(true); + } + return accepted; + } catch (RuntimeException failure) { + debug("Unable to send HTTP timed broadcast delivery: " + failure.getMessage()); + return false; + } + } + /** * Sends a standalone proxy broadcast through the selected transport and reports * whether that transport accepted the message. @@ -566,11 +829,36 @@ protected boolean sendProxyBroadcastEnvelopeNow(String server, JsonEnvelope enve // envelopes. This preserves the socket connection and its delivery // acknowledgement instead of creating a second short-lived socket. return sendSocketEnvelope(server, envelope); + case HTTP: + return sendHttpEnvelope(server, envelope); default: return false; } } + /** + * Sends a reward-bearing vote envelope and reports whether the selected + * transport accepted it. Legacy transports retain their existing asynchronous + * semantics; HTTP exposes its bounded-queue result so a vote is never discarded + * when the queue is full. + */ + protected boolean sendVoteEnvelopeAccepted(String server, int delay, JsonEnvelope envelope) { + return sendVoteEnvelopeAccepted(server, delay, envelope, null); + } + + protected boolean sendVoteEnvelopeAccepted(String server, int delay, JsonEnvelope envelope, + OfflineBungeeVote cachedVote) { + if (method == BungeeMethod.HTTP) { + return sendHttpEnvelopeWithRecovery(server, envelope, cachedVote); + } + GlobalMessageProxyHandler handler = globalMessageProxyHandler; + if (handler == null) { + return false; + } + handler.sendMessage(server, delay, envelope); + return true; + } + public synchronized void checkCachedVotes(String server) { int delay = 1; if (isServerValid(server)) { @@ -588,13 +876,18 @@ public synchronized void checkCachedVotes(String server) { if (cache.isProxyBroadcastHandled() && cache.needsBroadcastOn(server)) { Set forwarded = sendProxyBroadcast(Collections.singleton(server), cache.getUuid(), cache.getPlayerName(), cache.getService(), cache.getTime(), - cache.getText(), false); - if (cache.getBroadcastForwardedServers().addAll(forwarded)) { + cache.getText(), false, cache); + boolean broadcastChanged = cache.getBroadcastForwardedServers().addAll(forwarded); + if (broadcastChanged) { cache.setBroadcastForwarded(cache.isProxyBroadcastComplete()); - if (!persistServerVoteDelivery(server, cache)) { - continue; - } } + if ((broadcastChanged || cache.isDeliveryStateDirty()) + && !persistServerVoteDelivery(server, cache)) continue; + } + if (cache.isRewardDelivered()) { + if (cache.isProxyBroadcastHandled() && !cache.isProxyBroadcastComplete()) continue; + removed.add(cache); + continue; } boolean toSend = true; @@ -607,7 +900,7 @@ public synchronized void checkCachedVotes(String server) { } } if (toSend) { - boolean broadcastHere = cache.needsBroadcastOn(server); + boolean broadcastHere = !cache.isProxyBroadcastHandled() && cache.needsBroadcastOn(server); if (!cache.isProxyBroadcastHandled() && broadcastHere && getConfig().getProxyBroadcastEnabled()) { boolean playerOnline = isPlayerOnlineForVoteRouting(cache.getPlayerName()); @@ -619,13 +912,24 @@ && getConfig().getProxyBroadcastEnabled()) { broadcastHere = proxyBroadcastDecider.shouldBroadcast(server, targets); } - globalMessageProxyHandler.sendMessage(server, delay, + if (!sendVoteEnvelopeAccepted(server, delay, VotingPluginWire.vote(cache.getPlayerName(), cache.getUuid(), cache.getService(), cache.getTime(), false, cache.isRealVote(), cache.getText(), cache.getVoteId(), getConfig().getBungeeManageTotals(), - broadcastHere, num, numberOfVotes)); + broadcastHere, num, numberOfVotes), cache)) { + debug("Retaining cached vote because the transport rejected delivery for " + server); + persistServerVoteDelivery(server, cache); + continue; + } delay++; num++; + cache.setRewardDelivered(true); + if (!persistServerVoteDelivery(server, cache)) { + continue; + } + if (cache.isProxyBroadcastHandled() && !cache.isProxyBroadcastComplete()) { + continue; + } removed.add(cache); } else { debug("Not sending vote because user isn't on server " + server + ": " @@ -657,8 +961,19 @@ public synchronized void checkOnlineVotes(String player, String uuid, String ser int num = 1; int numberOfVotes = (int) c.stream().filter(vote -> !vote.isRewardDelivered()).count(); boolean deliveredReward = false; - ArrayList retained = new ArrayList<>(); - for (OfflineBungeeVote cache : c) { + for (OfflineBungeeVote cache : new ArrayList<>(c)) { + if (isIncompleteRewardJournalOwner(cache)) { + if (!materializeRewardJournalOwner(uuid, cache)) continue; + if (!cache.isProxyBroadcastHandled() || cache.isProxyBroadcastComplete()) { + if (!getVoteCacheHandler().tryRemoveOnlineVote(uuid, cache)) { + scheduleCachedVoteDeliveryRetry(); + } + } + continue; + } + if (cache.isDeliveryStateDirty() && !persistOnlineVoteDelivery(uuid, cache)) { + continue; + } if (cache.isProxyBroadcastHandled()) { Set pendingTargets = new LinkedHashSet<>(cache.getBroadcastTargets()); pendingTargets.removeAll(cache.getBroadcastForwardedServers()); @@ -666,12 +981,14 @@ public synchronized void checkOnlineVotes(String player, String uuid, String ser if (blockedServers != null) { pendingTargets.removeAll(blockedServers); } - cache.getBroadcastForwardedServers().addAll(sendProxyBroadcast(pendingTargets, + boolean broadcastChanged = cache.getBroadcastForwardedServers().addAll(sendProxyBroadcast(pendingTargets, cache.getUuid(), cache.getPlayerName(), cache.getService(), cache.getTime(), - cache.getText(), false)); + cache.getText(), false, cache)); cache.setBroadcastForwarded(cache.isProxyBroadcastComplete()); + if ((broadcastChanged || cache.isDeliveryStateDirty()) + && !persistOnlineVoteDelivery(uuid, cache)) continue; } - boolean broadcastHere = cache.needsBroadcastOn(server); + boolean broadcastHere = !cache.isProxyBroadcastHandled() && cache.needsBroadcastOn(server); if (!cache.isProxyBroadcastHandled() && broadcastHere && getConfig().getProxyBroadcastEnabled()) { String playerServer = (server != null) ? server : getCurrentPlayerServerForVoteRouting(player); @@ -681,31 +998,25 @@ && getConfig().getProxyBroadcastEnabled()) { } if (!cache.isRewardDelivered()) { - globalMessageProxyHandler.sendMessage(server, delay, + if (!sendVoteEnvelopeAccepted(server, delay, VotingPluginWire.voteOnline(cache.getPlayerName(), cache.getUuid(), cache.getService(), cache.getTime(), false, cache.isRealVote(), cache.getText(), cache.getVoteId(), - getConfig().getBungeeManageTotals(), broadcastHere, num, numberOfVotes)); - // The normal envelope is also a valid broadcast delivery for the - // current target. Record it so a previously pending standalone - // retry cannot announce the same vote again later. - if (cache.isProxyBroadcastHandled() && broadcastHere) { - cache.getBroadcastForwardedServers().add(server); - cache.setBroadcastForwarded(cache.isProxyBroadcastComplete()); + getConfig().getBungeeManageTotals(), broadcastHere, num, numberOfVotes), cache)) { + debug("Retaining online vote because the transport rejected delivery for " + server); + persistOnlineVoteDelivery(uuid, cache); + continue; } cache.setRewardDelivered(true); + if (!persistOnlineVoteDelivery(uuid, cache)) continue; deliveredReward = true; delay++; num++; } - if (cache.isProxyBroadcastHandled() && !cache.isProxyBroadcastComplete()) { - retained.add(cache); + if (!cache.isProxyBroadcastHandled() || cache.isProxyBroadcastComplete()) { + getVoteCacheHandler().removeOnlineVote(uuid, cache); } } - getVoteCacheHandler().removeOnlineVotes(uuid); - for (OfflineBungeeVote pending : retained) { - getVoteCacheHandler().addOnlineVote(uuid, pending); - } // multiproxy: envelope-only if (deliveredReward && getConfig().getMultiProxySupport() @@ -730,6 +1041,12 @@ protected synchronized void retryPendingOnlineBroadcasts(String server) { } for (String cachedUuid : getVoteCacheHandler().getOnlineVoteUUIDs()) { for (OfflineBungeeVote cache : new ArrayList<>(getVoteCacheHandler().getOnlineVotes(cachedUuid))) { + if (isIncompleteRewardJournalOwner(cache) && !materializeRewardJournalOwner(cachedUuid, cache)) { + continue; + } + if (retryCompletedRewardJournalOwner(cachedUuid, cache)) { + continue; + } if (cache.isDeliveryStateDirty() && !persistOnlineVoteDelivery(cachedUuid, cache)) { continue; } @@ -737,7 +1054,7 @@ protected synchronized void retryPendingOnlineBroadcasts(String server) { continue; } Set forwarded = sendProxyBroadcast(Collections.singleton(server), cache.getUuid(), - cache.getPlayerName(), cache.getService(), cache.getTime(), cache.getText(), false); + cache.getPlayerName(), cache.getService(), cache.getTime(), cache.getText(), false, cache); if (cache.getBroadcastForwardedServers().addAll(forwarded)) { cache.setBroadcastForwarded(cache.isProxyBroadcastComplete()); if (cache.isRewardDelivered() && cache.isProxyBroadcastComplete()) { @@ -745,6 +1062,8 @@ protected synchronized void retryPendingOnlineBroadcasts(String server) { } else { persistOnlineVoteDelivery(cachedUuid, cache); } + } else if (cache.isDeliveryStateDirty()) { + persistOnlineVoteDelivery(cachedUuid, cache); } } } @@ -767,8 +1086,8 @@ protected synchronized void retryPendingTimeBroadcasts(String server) { continue; } Set forwarded = sendProxyBroadcast(Collections.singleton(server), vote.getUuid(), vote.getName(), - vote.getService(), vote.getTime(), vote.getTotals(), false); - if (vote.getBroadcastForwardedServers().addAll(forwarded)) { + vote.getService(), vote.getTime(), vote.getTotals(), false, vote); + if (vote.getBroadcastForwardedServers().addAll(forwarded) || vote.isDeliveryStateDirty()) { persistTimeVoteDelivery(vote); } } @@ -780,12 +1099,26 @@ protected synchronized void retryPendingTimeBroadcasts(String server) { * carrier event. */ public synchronized void retryPendingOnlineBroadcasts() { + if (!getVoteCacheHandler().retryPendingVotePersistence()) { + scheduleCachedVoteDeliveryRetry(); + } for (String cachedUuid : new LinkedHashSet<>(getVoteCacheHandler().getOnlineVoteUUIDs())) { for (OfflineBungeeVote cache : new ArrayList<>(getVoteCacheHandler().getOnlineVotes(cachedUuid))) { + if (isIncompleteRewardJournalOwner(cache) && !materializeRewardJournalOwner(cachedUuid, cache)) { + continue; + } + if (retryCompletedRewardJournalOwner(cachedUuid, cache)) { + continue; + } if (cache.isDeliveryStateDirty() && !persistOnlineVoteDelivery(cachedUuid, cache)) { continue; } - if (!cache.isProxyBroadcastHandled() || cache.isProxyBroadcastComplete()) { + if (cache.isProxyBroadcastHandled() && cache.isRewardDelivered() + && cache.isProxyBroadcastComplete()) { + getVoteCacheHandler().removeOnlineVote(cachedUuid, cache); + continue; + } + if (!cache.isProxyBroadcastHandled()) { continue; } Set pendingTargets = new LinkedHashSet<>(cache.getBroadcastTargets()); @@ -795,7 +1128,7 @@ public synchronized void retryPendingOnlineBroadcasts() { pendingTargets.removeAll(blockedServers); } Set forwarded = sendProxyBroadcast(pendingTargets, cache.getUuid(), cache.getPlayerName(), - cache.getService(), cache.getTime(), cache.getText(), false); + cache.getService(), cache.getTime(), cache.getText(), false, cache); if (cache.getBroadcastForwardedServers().addAll(forwarded)) { cache.setBroadcastForwarded(cache.isProxyBroadcastComplete()); if (cache.isRewardDelivered() && cache.isProxyBroadcastComplete()) { @@ -803,6 +1136,8 @@ public synchronized void retryPendingOnlineBroadcasts() { } else { persistOnlineVoteDelivery(cachedUuid, cache); } + } else if (cache.isDeliveryStateDirty()) { + persistOnlineVoteDelivery(cachedUuid, cache); } } } @@ -827,8 +1162,8 @@ public synchronized void retryPendingTimeBroadcasts() { pendingTargets.removeAll(blockedServers); } Set forwarded = sendProxyBroadcast(pendingTargets, vote.getUuid(), vote.getName(), vote.getService(), - vote.getTime(), vote.getTotals(), false); - if (vote.getBroadcastForwardedServers().addAll(forwarded)) { + vote.getTime(), vote.getTotals(), false, vote); + if (vote.getBroadcastForwardedServers().addAll(forwarded) || vote.isDeliveryStateDirty()) { persistTimeVoteDelivery(vote); } } @@ -901,6 +1236,9 @@ private void scheduleCachedVoteDeliveryRetry() { } private synchronized void retryCachedVoteDeliveryPersistence() { + if (!getVoteCacheHandler().retryPendingVotePersistence()) { + scheduleCachedVoteDeliveryRetry(); + } for (String server : getVoteCacheHandler().getCachedVotesServers()) { for (OfflineBungeeVote vote : new ArrayList<>(getVoteCacheHandler().getVotes(server))) { if (vote.isDeliveryStateDirty()) { @@ -910,6 +1248,7 @@ private synchronized void retryCachedVoteDeliveryPersistence() { } for (String uuid : new LinkedHashSet<>(getVoteCacheHandler().getOnlineVoteUUIDs())) { for (OfflineBungeeVote vote : new ArrayList<>(getVoteCacheHandler().getOnlineVotes(uuid))) { + if (retryCompletedRewardJournalOwner(uuid, vote)) continue; if (vote.isDeliveryStateDirty()) { persistOnlineVoteDelivery(uuid, vote); } @@ -917,36 +1256,107 @@ private synchronized void retryCachedVoteDeliveryPersistence() { } } - public void checkVoteParty() { - if (getConfig().getVotePartyEnabled()) { - if (votePartyVotes >= currentVotePartyVotesRequired) { - debug("Vote party reached"); - addCurrentVotePartyVotes(-currentVotePartyVotesRequired); + public synchronized void checkVoteParty() { + if (!getConfig().getVotePartyEnabled()) return; + if (votePartyVotes < currentVotePartyVotesRequired) { + saveVoteCacheFile(); + return; + } + if (!retryPendingVotePartyProxyEffects()) { + persistRetainedVotePartyThreshold(); + return; + } + PendingVotePartyProxyEffects stagedProxyEffects = PendingVotePartyProxyEffects.empty(); + if (method == BungeeMethod.HTTP) { + try { + stagedProxyEffects = new PendingVotePartyProxyEffects(getConfig().getVotePartyBroadcast(), + getConfig().getVotePartyBungeeCommands()); + } catch (IllegalArgumentException oversized) { + logSevere("HTTP vote-party proxy effects exceed the durable backlog limit; retaining the vote-party threshold"); + persistRetainedVotePartyThreshold(); + return; + } + } + Collection targets = getConfig().getVotePartySendToAllServers() + ? getAllAvailableServers() : getConfig().getVotePartyServersToSend(); + Map onlineTargets = onlineVotePartyTargets(targets); + if (method == BungeeMethod.HTTP && !canQueueVotePartyRewards(onlineTargets)) { + try { + saveVotePartyStateDurably(); + } catch (IOException failure) { + throw new IllegalStateException("Unable to retain the full HTTP vote-party backlog", failure); + } + return; + } - currentVotePartyVotesRequired += getConfig().getVotePartyIncreaseVotesRequired(); - setVoteCacheVotePartyIncreaseVotesRequired( - getVoteCacheVotePartyIncreaseVotesRequired() + getConfig().getVotePartyIncreaseVotesRequired()); + Map stagedRewards = new LinkedHashMap<>(); + if (method == BungeeMethod.HTTP) { + for (String canonicalServer : onlineTargets.keySet()) { + String deliveryId = UUID.randomUUID().toString(); + setVoteCachePendingVotePartyReward(canonicalServer, deliveryId, true); + stagedRewards.put(canonicalServer, deliveryId); + } + } + int previousVotes = votePartyVotes; + int previousRequired = currentVotePartyVotesRequired; + int previousIncrease = getVoteCacheVotePartyIncreaseVotesRequired(); + PendingVotePartyProxyEffects previousProxyEffects = method == BungeeMethod.HTTP + ? getVoteCachePendingVotePartyProxyEffects() : PendingVotePartyProxyEffects.empty(); + if (method == BungeeMethod.HTTP) setVoteCachePendingVotePartyProxyEffects(stagedProxyEffects); + debug("Vote party reached"); + addCurrentVotePartyVotes(-currentVotePartyVotesRequired); + currentVotePartyVotesRequired += getConfig().getVotePartyIncreaseVotesRequired(); + setVoteCacheVotePartyIncreaseVotesRequired( + previousIncrease + getConfig().getVotePartyIncreaseVotesRequired()); + try { + if (method == BungeeMethod.HTTP) saveVotePartyStateDurably(); + else saveVoteCacheFile(); + } catch (IOException | RuntimeException failure) { + votePartyVotes = previousVotes; + setVoteCacheVotePartyCurrentVotes(previousVotes); + currentVotePartyVotesRequired = previousRequired; + setVoteCacheVotePartyIncreaseVotesRequired(previousIncrease); + for (Map.Entry staged : stagedRewards.entrySet()) + setVoteCachePendingVotePartyReward(staged.getKey(), staged.getValue(), false); + if (method == BungeeMethod.HTTP) setVoteCachePendingVotePartyProxyEffects(previousProxyEffects); + throw failure instanceof RuntimeException runtime ? runtime + : new IllegalStateException("Unable to persist HTTP vote-party rewards", failure); + } + + if (method == BungeeMethod.HTTP) { + if (retryPendingVotePartyProxyEffects()) retryPendingVotePartyRewards(); + } else { + if (!getConfig().getVotePartyBroadcast().isEmpty()) broadcast(getConfig().getVotePartyBroadcast()); + for (String command : getConfig().getVotePartyBungeeCommands()) runConsoleCommand(command); + for (String server : targets) sendVoteParty(server); + } + } - if (!getConfig().getVotePartyBroadcast().isEmpty()) { - broadcast(getConfig().getVotePartyBroadcast()); - } + private void persistRetainedVotePartyThreshold() { + try { + saveVotePartyStateDurably(); + } catch (IOException failure) { + throw new IllegalStateException("Unable to retain the HTTP vote-party threshold", failure); + } + } - for (String command : getConfig().getVotePartyBungeeCommands()) { - runConsoleCommand(command); - } + private Map onlineVotePartyTargets(Collection targets) { + Map online = new LinkedHashMap<>(); + for (String server : targets) if (isSomeoneOnlineServerForVoteRouting(server)) + online.putIfAbsent(server.toLowerCase(Locale.ROOT), server); + return online; + } - if (getConfig().getVotePartySendToAllServers()) { - for (String server : getAllAvailableServers()) { - sendVoteParty(server); - } - } else { - for (String server : getConfig().getVotePartyServersToSend()) { - sendVoteParty(server); - } - } + private boolean canQueueVotePartyRewards(Map targets) { + for (String server : targets.keySet()) { + Collection pending = getVoteCachePendingVotePartyRewardIds(server); + if (pending != null && pending.size() >= MAX_PENDING_VOTE_PARTY_REWARDS) { + logSevere("HTTP vote-party reward backlog is full for " + targets.get(server) + + "; retaining the vote-party threshold"); + return false; } - saveVoteCacheFile(); } + return true; } public abstract void debug(String str); @@ -1154,6 +1564,16 @@ protected int[] getProjectedVotePartyState(int acceptedVotes) { public abstract int getVoteCacheVotePartyIncreaseVotesRequired(); + public abstract Collection getVoteCachePendingVotePartyServers(); + + public abstract Collection getVoteCachePendingVotePartyRewardIds(String server); + + public abstract PendingVotePartyProxyEffects getVoteCachePendingVotePartyProxyEffects(); + + public abstract PendingVotePartyProxyEffects getVoteCacheQuarantinedVotePartyProxyEffects(); + + public abstract void saveVotePartyStateDurably() throws IOException; + public abstract boolean isPlayerOnline(String playerName); /** @@ -1248,6 +1668,7 @@ public void debug1(Throwable e) { } }; voteCacheHandler.load(); + method = retainHttpForPendingDeliveries(method); nonVotedPlayersCache = new NonVotedPlayersCache(getNonVotedCacheMySQLConfig(), getConfig().getNonVotedCacheUseMySQL(), getConfig().getNonVotedCacheUseMainMySQL(), @@ -1391,6 +1812,9 @@ public void sendMessage(String server, int delay, JsonEnvelope envelope) { case SOCKETS: sendSocketEnvelope(server, envelope); break; + case HTTP: + sendGenericHttpEnvelope(server, envelope); + break; default: break; } @@ -1553,6 +1977,12 @@ public void onReceive(JsonEnvelope message) { PRESENCE_MAINTENANCE_INTERVAL_SECONDS); } startControlServices(); + // Open the listener last: backend callbacks can immediately reach routing, + // presence, vote-log, multi-proxy, and Control-adjacent runtime helpers. + if (method.equals(BungeeMethod.HTTP)) { + startHttpTransport(); + } + scheduleVotePartyDeliveryRetry(); debug("VotingPluginProxy loaded, ONLINEMODE: " + getConfig().getOnlineMode()); } @@ -1986,10 +2416,233 @@ public void triggerVote(String player, String service, boolean realVote, boolean VoteTotalsSnapshot text, String uuid) { vote(player, service, realVote, timeQueue, queueTime, text, uuid); } + + @Override + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId); + } + + @Override + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, String origin) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId, origin); + } + + @Override + public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) { + handleMultiProxyVoteAcknowledgement(voteId, recipient); + } + + @Override + public void onMultiProxyVoteRetirementAcknowledged(UUID voteId, String recipient) { + handleMultiProxyVoteRetirementAcknowledgement(voteId, recipient); + } + + @Override + public void onMultiProxyVoteRetirementRequested(UUID voteId, String origin) { + handleMultiProxyVoteRetirementRequest(voteId, origin); + } }; multiProxyHandler.loadMultiProxySupport(); } + /** Receives and locally retries a forwarded vote under its wire-stable identity. */ + protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, + boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId) { + receiveMultiProxyVote(player, service, realVote, timeQueue, queueTime, totals, uuid, wireVoteId, ""); + } + + /** Receives a reliable envelope and retains its sender only for a later ACK. */ + protected synchronized void receiveMultiProxyVote(String player, String service, boolean realVote, + boolean timeQueue, long queueTime, VoteTotalsSnapshot totals, String uuid, UUID wireVoteId, String origin) { + UUID voteId = wireVoteId == null ? UUID.randomUUID() : wireVoteId; + if (completedMultiProxyVotes.containsKey(voteId)) { + acknowledgeCompletedMultiProxyVote(voteId, origin); + return; + } + if (getVoteCacheHandler().hasMultiProxyVoteCompletion(voteId)) { + rememberCompletedMultiProxyVote(voteId); + acknowledgeCompletedMultiProxyVote(voteId, origin); + return; + } + MultiProxyVoteRetry retry = multiProxyVoteRetries.get(voteId); + if (retry == null) { + if (multiProxyVoteRetries.size() >= MAX_MULTI_PROXY_VOTE_RETRIES) { + // Do not drop a sender delivery when the in-memory retry fence is full. + // The ordinary timed-vote cache is bounded by durable storage rather than + // 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)) { + scheduleTimeVoteRetry(); + return; + } + logSevere("Unable to durably retain forwarded multi-proxy vote while the bounded retry queue is full"); + return; + } + retry = new MultiProxyVoteRetry(player, service, realVote, timeQueue, queueTime, totals, uuid, voteId, origin); + multiProxyVoteRetries.put(voteId, retry); + } + if (!retry.scheduled) attemptMultiProxyVote(retry); + } + + /** 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) { + if (voteId == null || player == null || service == null || uuid == null) return false; + for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) { + if (voteId.equals(queued.getVoteId())) return true; + } + 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.setRealVote(realVote); + queued.setMultiProxyOrigin(origin == null ? "" : origin); + return getVoteCacheHandler().addTimeVoteToCache(queued); + } + + private static String[] decodeForwardedQueueTotals(String encoded) { + if (encoded == null || !encoded.startsWith(FORWARDED_QUEUE_TOTALS_PREFIX)) { + return new String[] { "true", encoded == null ? "" : encoded }; + } + int flagIndex = FORWARDED_QUEUE_TOTALS_PREFIX.length(); + int separator = flagIndex + 1; + if (encoded.length() <= separator || (encoded.charAt(flagIndex) != '0' && encoded.charAt(flagIndex) != '1') + || encoded.charAt(separator) != ':') { + return new String[] { "true", encoded }; + } + char realVote = encoded.charAt(FORWARDED_QUEUE_TOTALS_PREFIX.length()); + try { + String decoded = new String(Base64.getUrlDecoder().decode(encoded.substring(separator + 1)), StandardCharsets.UTF_8); + return new String[] { realVote == '1' ? "true" : "false", decoded }; + } catch (IllegalArgumentException invalidEncoding) { + return new String[] { "true", encoded }; + } + } + + private synchronized void attemptMultiProxyVote(MultiProxyVoteRetry retry) { + attemptMultiProxyVote(retry, true); + } + + private synchronized void attemptMultiProxyVote(MultiProxyVoteRetry retry, boolean allowSchedule) { + if (multiProxyVoteRetries.get(retry.voteId) != retry) return; + retry.scheduled = false; + if (!enabled) return; + if (retry.phase == MultiProxyVoteRetry.Phase.PERSIST_DEFERRED_RECEIPT) { + if (persistDeferredMultiProxyReceipt(retry)) { + multiProxyVoteRetries.remove(retry.voteId); + } else { + scheduleMultiProxyVoteRetry(retry, allowSchedule, "deferred-receipt persistence"); + } + return; + } + if (retry.phase == MultiProxyVoteRetry.Phase.PERSIST_COMPLETION) { + if (getVoteCacheHandler().hasMultiProxyVoteCompletion(retry.voteId) + || getVoteCacheHandler().markMultiProxyVoteCompletedDurably(retry.voteId)) { + completeMultiProxyVote(retry); + } else { + scheduleMultiProxyVoteRetry(retry, allowSchedule, "completion-record persistence"); + } + return; + } + try { + vote(retry.player, retry.service, retry.realVote, retry.timeQueue, retry.queueTime, retry.totals, + retry.uuid, retry.voteId); + if (retry.timeQueue && findUnprocessedQueuedVote(retry.voteId) != null) { + retry.phase = MultiProxyVoteRetry.Phase.PERSIST_DEFERRED_RECEIPT; + retry.attempts = 0; + if (persistDeferredMultiProxyReceipt(retry)) multiProxyVoteRetries.remove(retry.voteId); + else scheduleMultiProxyVoteRetry(retry, allowSchedule, "deferred-receipt persistence"); + return; + } + retry.phase = MultiProxyVoteRetry.Phase.PERSIST_COMPLETION; + retry.attempts = 0; + if (getVoteCacheHandler().markMultiProxyVoteCompletedDurably(retry.voteId)) { + completeMultiProxyVote(retry); + } else { + scheduleMultiProxyVoteRetry(retry, allowSchedule, "completion-record persistence"); + } + } catch (VoteRetryException retryable) { + scheduleMultiProxyVoteRetry(retry, allowSchedule, "durable-storage"); + } + } + + /** Binds a deferred receiver queue to its origin before dropping its live retry fence. */ + private boolean persistDeferredMultiProxyReceipt(MultiProxyVoteRetry retry) { + VoteTimeQueue queued = findUnprocessedQueuedVote(retry.voteId); + if (queued == null) return false; + queued.setMultiProxyOrigin(retry.origin); + queued.setRealVote(retry.realVote); + queued.setDeliveryStateDirty(true); + return persistTimeVoteDelivery(queued); + } + + private VoteTimeQueue findUnprocessedQueuedVote(UUID voteId) { + if (voteId == null) return null; + for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) { + if (voteId.equals(queued.getVoteId()) && !queued.isProcessed()) return queued; + } + return null; + } + + private void completeMultiProxyVote(MultiProxyVoteRetry retry) { + multiProxyVoteRetries.remove(retry.voteId); + rememberCompletedMultiProxyVote(retry.voteId); + acknowledgeCompletedMultiProxyVote(retry.voteId, retry.origin); + } + + private void acknowledgeCompletedMultiProxyVote(UUID voteId, String origin) { + if (origin == null || origin.isBlank() || multiProxyHandler == null) return; + multiProxyHandler.acknowledgeMultiProxyVote(voteId, origin); + } + + private synchronized void handleMultiProxyVoteRetirementRequest(UUID voteId, String origin) { + if (voteId == null || origin == null || origin.isBlank() || multiProxyHandler == null) return; + for (VoteTimeQueue queued : getVoteCacheHandler().getTimeChangeQueue()) { + if (!voteId.equals(queued.getVoteId())) continue; + // A completion tombstone can be the only durable proof while deletion of + // the receiver's processed queue row is retrying. Retire that row first, + // otherwise deleting the tombstone would let the local queue replay it. + if (!queued.isProcessed() || !origin.equalsIgnoreCase(queued.getMultiProxyOrigin()) + || !getVoteCacheHandler().removeTimeVote(queued)) return; + break; + } + if (!getVoteCacheHandler().removeMultiProxyVoteCompletion(voteId)) return; + removeCompletedMultiProxyVote(voteId); + multiProxyHandler.acknowledgeMultiProxyVoteRetirement(voteId, origin); + } + + private void scheduleMultiProxyVoteRetry(MultiProxyVoteRetry retry, boolean allowSchedule, String reason) { + retry.attempts++; + ScheduledExecutorService scheduler = getScheduler(); + if (!allowSchedule || retry.attempts >= MAX_MULTI_PROXY_VOTE_ATTEMPTS + || scheduler == null || scheduler.isShutdown()) { + if (!allowSchedule) return; + logSevere("Forwarded multi-proxy vote remains fenced after bounded " + reason + " retries for " + + MinecraftUsernameValidator.sanitizeForLog(retry.player)); + return; + } + retry.scheduled = true; + try { + scheduler.schedule(retry, 5, TimeUnit.SECONDS); + } catch (RuntimeException schedulingFailure) { + retry.scheduled = false; + logSevere("Unable to schedule a forwarded multi-proxy vote retry; its live side-effect fence was retained"); + } + } + + private void rememberCompletedMultiProxyVote(UUID voteId) { + completedMultiProxyVotes.put(voteId, Boolean.TRUE); + while (completedMultiProxyVotes.size() > MAX_COMPLETED_MULTI_PROXY_VOTES) { + UUID oldest = completedMultiProxyVotes.keySet().iterator().next(); + removeCompletedMultiProxyVote(oldest); + } + } + + private void removeCompletedMultiProxyVote(UUID voteId) { + completedMultiProxyVotes.remove(voteId); + } + public abstract void log(String message); /** @@ -2378,9 +3031,26 @@ public void onDisable() { /** Full runtime replacement waits for hosted workers; final proxy stop remains non-blocking. */ public void onDisable(boolean waitForHosted) { + invalidateDeferredHttpTransportReconciliation(); if (waitForHosted) { prepareForRuntimeReplacement(); } else { + boolean liveRetriesSettled; + try { + liveRetriesSettled = settleMultiProxyAndLiveVotesForFinalShutdown(); + } catch (RuntimeException failure) { + liveRetriesSettled = false; + } + if (!liveRetriesSettled) { + logSevere("Unable to durably quarantine live vote retries after bounded final-shutdown persistence attempts; operator reconciliation may be required after restart"); + } + if (!quarantineInFlightVotePartyProxyCommandForReplacement()) { + awaitInFlightVotePartyProxyCommand(); + if (!quarantineInFlightVotePartyProxyCommandForReplacement()) { + logSevere("Unable to durably quarantine an in-flight HTTP vote-party command after the bounded final-shutdown wait; operator reconciliation may be required after restart"); + } + } + cancelPreparedHttpTransportChange(); controlServicesGeneration.incrementAndGet(); controlLifecycleExecutor.shutdownNow(); stopControlServices(false); @@ -2388,8 +3058,130 @@ public void onDisable(boolean waitForHosted) { completeRuntimeReplacementShutdown(); } + /** Settles consumed multi-proxy envelopes together with any live phase fences they own. */ + protected synchronized boolean settleMultiProxyAndLiveVotesForFinalShutdown() { + for (int attempt = 0; attempt < FINAL_SHUTDOWN_PERSISTENCE_ATTEMPTS; attempt++) { + for (MultiProxyVoteRetry retry : new ArrayList<>(multiProxyVoteRetries.values())) { + retry.scheduled = false; + attemptMultiProxyVote(retry, false); + } + Set activeForwardedVotes = new LinkedHashSet<>(); + for (UUID voteId : multiProxyVoteRetries.keySet()) { + if (liveVoteRetries.containsKey(voteId)) activeForwardedVotes.add(voteId); + } + boolean liveSettled = settleLiveVoteRetriesForFinalShutdown(); + for (UUID voteId : activeForwardedVotes) { + if (!liveVoteRetries.containsKey(voteId)) { + MultiProxyVoteRetry retry = multiProxyVoteRetries.get(voteId); + if (retry != null) { + retry.phase = MultiProxyVoteRetry.Phase.PERSIST_COMPLETION; + retry.attempts = 0; + attemptMultiProxyVote(retry, false); + } + } + } + if (liveSettled && multiProxyVoteRetries.isEmpty()) return true; + } + // Platform disable callers always continue teardown. A receiver whose vote + // side effects completed but whose separate tombstone could not be written + // must therefore be represented by the ordinary durable queue, not retained + // only by this process's retry map. + if (liveVoteRetries.isEmpty() && quarantineCompletedMultiProxyRetriesForShutdown()) return true; + return liveVoteRetries.isEmpty() && multiProxyVoteRetries.isEmpty(); + } + + private boolean quarantineCompletedMultiProxyRetriesForShutdown() { + VoteCacheHandler cache = getVoteCacheHandler(); + for (MultiProxyVoteRetry retry : new ArrayList<>(multiProxyVoteRetries.values())) { + if (retry.phase != MultiProxyVoteRetry.Phase.PERSIST_COMPLETION + || !retainCompletedMultiProxyRetry(cache, retry)) return false; + multiProxyVoteRetries.remove(retry.voteId); + } + return true; + } + + /** Persists a completed receiver phase without acknowledging until its tombstone succeeds. */ + private boolean retainCompletedMultiProxyRetry(VoteCacheHandler cache, MultiProxyVoteRetry retry) { + if (retry.voteId == null) return false; + for (VoteTimeQueue queued : cache.getTimeChangeQueue()) { + if (!retry.voteId.equals(queued.getVoteId())) continue; + // An unprocessed row owns deferred vote effects. Do not convert it into a + // completion fence: its origin is persisted by PERSIST_DEFERRED_RECEIPT. + if (!queued.isProcessed()) return false; + queued.setProcessed(true); + queued.setRealVote(retry.realVote); + queued.setMultiProxyOrigin(retry.origin); + queued.setMultiProxyCompletionPending(true); + queued.setDeliveryStateDirty(true); + return cache.updateTimeVote(queued); + } + VoteTimeQueue quarantine = new VoteTimeQueue(retry.voteId, retry.player, retry.service, + retry.queueTime == 0L ? System.currentTimeMillis() : retry.queueTime, false, + Collections.emptySet(), Collections.emptySet(), retry.totals == null ? "" : retry.totals.toString(), true, + retry.uuid); + quarantine.setRealVote(retry.realVote); + quarantine.setMultiProxyOrigin(retry.origin); + quarantine.setMultiProxyCompletionPending(true); + return cache.addTimeVoteToCache(quarantine); + } + + /** Moves every in-memory live retry into the ordinary durable vote outbox before final teardown. */ + protected synchronized boolean settleLiveVoteRetriesForFinalShutdown() { + if (liveVoteRetries.isEmpty()) return true; + VoteCacheHandler cache = getVoteCacheHandler(); + boolean retained = true; + for (LiveVoteRetryState retry : liveVoteRetries.values()) { + if (retry.queuedVote != null) { + Queue queuedVotes = cache.getTimeChangeQueue(); + if (queuedVotes == null || !queuedVotes.contains(retry.queuedVote)) { + retained &= cache.addTimeVoteToCache(retry.queuedVote); + } + } + Set onlineStates = Collections.newSetFromMap(new java.util.IdentityHashMap<>()); + if (retry.rewardJournalOwner != null) onlineStates.add(retry.rewardJournalOwner); + if (retry.standaloneBroadcastState != null) onlineStates.add(retry.standaloneBroadcastState); + if (retry.pendingOnlineRewardState != null) onlineStates.add(retry.pendingOnlineRewardState); + for (OfflineBungeeVote state : onlineStates) { + retained &= cache.retainOnlineVoteForPersistenceRetry(state.getUuid(), state); + } + for (Map.Entry entry : retry.rewardStates.entrySet()) { + String server = entry.getKey(); + for (String configured : getAllConfiguredServers()) { + if (configured.equalsIgnoreCase(server)) { + server = configured; + break; + } + } + retained &= cache.retainServerVoteForPersistenceRetry(server, entry.getValue()); + } + } + for (int attempt = 0; attempt < FINAL_SHUTDOWN_PERSISTENCE_ATTEMPTS; attempt++) { + if (!retained || !cache.retryPendingVotePersistence()) continue; + for (Map.Entry entry : new ArrayList<>(liveVoteRetries.entrySet())) { + LiveVoteRetryState retry = entry.getValue(); + QueuedVoteResult result = vote(retry.player, retry.service, retry.realVote, false, retry.time, + retry.totals, retry.uuid, retry.queuedVote, entry.getKey()); + if (result == QueuedVoteResult.TERMINAL) liveVoteRetries.remove(entry.getKey()); + } + if (liveVoteRetries.isEmpty()) return true; + } + return false; + } + /** Fail-closed gate that must complete before a replacement proxy runtime is created. */ - public void prepareForRuntimeReplacement() { + public synchronized void prepareForRuntimeReplacement() { + // Live retry markers fence side effects (including vote-party and totals) that + // have already run but whose durable outbox write has not. They are owned by + // this runtime only, so replacing it would let the scheduled listener retry + // start with an empty map and reapply those effects. Keep this runtime alive + // until the retry settles instead of dropping or replaying an uncertain vote. + if (!liveVoteRetries.isEmpty() || !multiProxyVoteRetries.isEmpty()) { + throw new IllegalStateException( + "Live vote retries or forwarded multi-proxy vote retries must settle before proxy runtime replacement"); + } + if (!quarantineInFlightVotePartyProxyCommandForReplacement()) { + throw new IllegalStateException("In-flight vote-party command must be durably quarantined before proxy runtime replacement"); + } controlServicesGeneration.incrementAndGet(); synchronized (controlLifecycleLock) { ControlConnector connector = controlConnector; @@ -2399,10 +3191,12 @@ public void prepareForRuntimeReplacement() { controlLifecycleExecutor.shutdown(); stopControlServicesLocked(true); } + runtimeReplacementPrepared = true; } /** Best-effort remainder of runtime teardown after the Control overlap gate has succeeded. */ public void completeRuntimeReplacementShutdown() { + enabled = false; cancelCommunicationTests("Proxy runtime stopped before the backend replied"); runCleanup("vote cache", () -> getVoteCacheHandler().saveVoteCache()); runCleanup("proxy MySQL messenger", () -> { @@ -2418,6 +3212,7 @@ public void completeRuntimeReplacementShutdown() { if (socketHandler != null) socketHandler.closeConnection(); }); runCleanup("socket clients", this::closeSocketClients); + runCleanup("HTTP transport", this::closeHttpTransport); runCleanup("Redis subscriber", () -> { if (redisHandler != null) redisHandler.close(); }); @@ -2436,7 +3231,6 @@ public void completeRuntimeReplacementShutdown() { runCleanup("global data", () -> { if (getGlobalDataHandler() != null) getGlobalDataHandler().shutdown(); }); - enabled = false; } private void runCleanup(String service, CleanupAction cleanup) { @@ -2553,26 +3347,96 @@ private UUID parseUUIDFromString(String uuidAsString) { } public synchronized void processQueue() { - while (getVoteCacheHandler().getTimeChangeQueue().size() > 0) { - VoteTimeQueue vote = getVoteCacheHandler().getTimeChangeQueue().element(); - if (!vote.isProcessed()) { - VoteTotalsSnapshot queuedTotals = vote.getTotals() == null || vote.getTotals().isEmpty() ? null - : VoteTotalsSnapshot.parseStorage(vote.getTotals()); - QueuedVoteResult result = vote(vote.getName(), vote.getService(), true, false, vote.getTime(), queuedTotals, - vote.getUuid(), vote); - if (result == QueuedVoteResult.RETRY) { + java.util.Queue timeChangeQueue = getVoteCacheHandler().getTimeChangeQueue(); + // Work from a bounded snapshot so an already-processed ACK outbox can remain + // durable without monopolizing the head of the rollover queue. + for (VoteTimeQueue vote : new ArrayList<>(timeChangeQueue)) { + if (!timeChangeQueue.contains(vote)) continue; + if (vote.isMultiProxyCompletionPending()) { + if (!getVoteCacheHandler().hasMultiProxyVoteCompletion(vote.getVoteId()) + && !getVoteCacheHandler().markMultiProxyVoteCompletedDurably(vote.getVoteId())) { scheduleTimeVoteRetry(); return; } - if (result == QueuedVoteResult.TERMINAL) { - warn("Removing terminal rollover vote " + vote.getVoteId() + " for " + vote.getName() + "/" - + ServiceSiteValidator.sanitizeForLog(vote.getService())); - } + acknowledgeCompletedMultiProxyVote(vote.getVoteId(), vote.getMultiProxyOrigin()); + if (!getVoteCacheHandler().removeTimeVote(vote)) { + scheduleTimeVoteRetry(); + return; + } + continue; + } + if (!vote.isProcessed() && getVoteCacheHandler().hasTimeVoteCompletion(vote)) { + vote.setProcessed(true); + vote.setDeliveryStateDirty(true); + } + if (vote.isProcessed() && vote.hasPendingHttpBroadcastDeliveryIds()) { + // The reward/totals work is already complete. Only retry the durable + // standalone broadcasts; removing this row would lose their stable IDs. + retryPendingTimeBroadcasts(); + if (vote.hasPendingHttpBroadcastDeliveryIds()) { + scheduleTimeVoteRetry(); + continue; + } + } + if (vote.isProcessed() && vote.isDeliveryStateDirty() && !persistTimeVoteDelivery(vote)) { + scheduleTimeVoteRetry(); + return; + } + if (vote.isProcessed() && vote.isMultiProxyForwardingRequired() + && !vote.isMultiProxyForwardingHandled()) { + if (!retryDurableMultiProxyOutbox(vote)) { + scheduleTimeVoteRetry(); + continue; + } + } + // A direct listener retry can still be queued after its ACK outbox completes. + // Keep that in-memory fence until the listener consumes it; queued-vote + // processing removes its own fence in vote() when no listener retry exists. + if (vote.isProcessed() && !vote.isMultiProxyForwardingRequired()) { + liveVoteRetries.remove(vote.getVoteId()); + } + if (!vote.isProcessed()) { + String[] forwardedTotals = decodeForwardedQueueTotals(vote.getTotals()); + boolean queuedRealVote = vote.isRealVote(); + // Existing NUL-prefixed emergency rows predate the explicit realVote + // column. Decode them once for compatibility, while new rows preserve + // PostgreSQL-safe plain totals. + if (vote.getTotals() != null && vote.getTotals().startsWith(FORWARDED_QUEUE_TOTALS_PREFIX)) { + queuedRealVote = Boolean.parseBoolean(forwardedTotals[0]); + vote.setTotals(forwardedTotals[1]); + vote.setRealVote(queuedRealVote); + vote.setDeliveryStateDirty(true); + } + VoteTotalsSnapshot queuedTotals = forwardedTotals[1].isEmpty() ? null + : VoteTotalsSnapshot.parseStorage(forwardedTotals[1]); + QueuedVoteResult result = replayQueuedVote(vote, queuedTotals, queuedRealVote); + if (result == QueuedVoteResult.RETRY || result == QueuedVoteResult.RETRY_NONBLOCKING) { + scheduleTimeVoteRetry(); + if (result == QueuedVoteResult.RETRY_NONBLOCKING) continue; + return; + } + if ((result == QueuedVoteResult.SUCCESS || result == QueuedVoteResult.TERMINAL) + && !vote.getMultiProxyOrigin().isBlank()) { + if (!getVoteCacheHandler().markMultiProxyVoteCompletedDurably(vote.getVoteId())) { + scheduleTimeVoteRetry(); + return; + } + acknowledgeCompletedMultiProxyVote(vote.getVoteId(), vote.getMultiProxyOrigin()); + } + if (result == QueuedVoteResult.TERMINAL) { + warn("Removing terminal rollover vote " + vote.getVoteId() + " for " + vote.getName() + "/" + + ServiceSiteValidator.sanitizeForLog(vote.getService())); + } + if (!getVoteCacheHandler().getTimeChangeQueue().contains(vote)) { + getVoteCacheHandler().clearTimeVoteCompletion(vote); + continue; + } } if (!getVoteCacheHandler().removeTimeVote(vote)) { scheduleTimeVoteRetry(); return; } + getVoteCacheHandler().clearTimeVoteCompletion(vote); } } @@ -2604,10 +3468,14 @@ public void reloadFromControl() { } private void reloadRuntime(boolean restartControlServices) { - method = BungeeMethod.getByName(getConfig().getBungeeMethod()); - if (getMethod() == null) { - method = BungeeMethod.PLUGINMESSAGING; - } + // A manual reload supersedes any nested deferred reload scheduled by an old + // runtime. The generation check prevents that task from rebuilding after + // shutdown or racing this explicit replacement. + invalidateDeferredHttpTransportReconciliation(); + BungeeMethod configuredMethod = BungeeMethod.getByName(getConfig().getBungeeMethod()); + if (configuredMethod == null) configuredMethod = BungeeMethod.PLUGINMESSAGING; + method = retainHttpForPendingDeliveries(configuredMethod); + scheduleDeferredHttpTransportReconciliation(); warnUnsupportedDedicatedVotingProxyMode(); if (!restartControlServices && method == BungeeMethod.SOCKETS) { rebuildSocketClients(); @@ -2621,6 +3489,228 @@ private void reloadRuntime(boolean restartControlServices) { } } + private synchronized BungeeMethod retainHttpForPendingDeliveries(BungeeMethod configuredMethod) { + if (configuredMethod == BungeeMethod.HTTP && httpTransportServer != null + && !hasChangedLiveHttpConfiguration()) { + deferredHttpTransportReconciliation = false; + return configuredMethod; + } + HttpProxyTransportServer transport = httpTransportServer; + if (transport != null && httpTransportHasPendingDeliveries(transport)) { + persistLiveHttpListenerSettings(); + deferredHttpTransportReconciliation = true; + logSevere("Retaining HTTP transport until durable deliveries are acknowledged"); + return BungeeMethod.HTTP; + } + if (transport == null) { + try { + if (httpQueueHasPersistedDeliveries( + getDataFolderPlugin().toPath().resolve("http").resolve("outgoing-v1"))) { + retainedHttpStartupSettings = loadRetainedHttpListenerSettingsUnchecked(); + deferredHttpTransportReconciliation = true; + logSevere("Retaining HTTP transport until persisted deliveries are acknowledged"); + return BungeeMethod.HTTP; + } + } catch (IOException unreadableQueue) { + // An unreadable durable queue is not proof that it is empty. Reopen HTTP so + // its normal bounded loader can validate or recover the state. + retainedHttpStartupSettings = loadRetainedHttpListenerSettingsUnchecked(); + deferredHttpTransportReconciliation = true; + logSevere("Retaining HTTP transport because its persisted delivery queue could not be inspected"); + return BungeeMethod.HTTP; + } + } + if (hasPendingCachedHttpDeliveries()) { + if (transport != null) persistLiveHttpListenerSettings(); + else retainedHttpStartupSettings = loadRetainedHttpListenerSettingsUnchecked(); + deferredHttpTransportReconciliation = true; + logSevere("Retaining HTTP transport until cached deliveries are acknowledged"); + return BungeeMethod.HTTP; + } + Collection servers = getVoteCachePendingVotePartyServers(); + if (servers != null) { + for (String server : servers) { + Collection rewards = getVoteCachePendingVotePartyRewardIds(server); + if (rewards != null && !rewards.isEmpty()) { + if (transport != null) persistLiveHttpListenerSettings(); + else retainedHttpStartupSettings = loadRetainedHttpListenerSettingsUnchecked(); + deferredHttpTransportReconciliation = true; + logSevere("Retaining HTTP transport until pending vote-party rewards are acknowledged"); + return BungeeMethod.HTTP; + } + } + } + deferredHttpTransportReconciliation = false; + retainedHttpStartupSettings = null; + if (configuredMethod != BungeeMethod.HTTP || hasChangedLiveHttpConfiguration()) { + clearRetainedHttpListenerSettings(); + } + return configuredMethod; + } + + private Path retainedHttpListenerSettingsPath() { + return getDataFolderPlugin().toPath().resolve("http").resolve("retained-listener-v1.bin"); + } + + private void persistLiveHttpListenerSettings() { + if (liveHttpHost == null || liveHttpPublicEndpoint == null || liveHttpPort <= 0) return; + try { + persistRetainedHttpListenerSettings( + new HttpListenerSettings(liveHttpHost, liveHttpPort, liveHttpPublicEndpoint)); + } catch (IOException failure) { + throw new IllegalStateException("HTTP listener settings could not be retained for queued deliveries", failure); + } + } + + private void persistRetainedHttpListenerSettings(HttpListenerSettings settings) throws IOException { + Path target = retainedHttpListenerSettingsPath(); + Files.createDirectories(target.getParent()); + ByteArrayOutputStream encoded = new ByteArrayOutputStream(); + try (DataOutputStream output = new DataOutputStream(encoded)) { + output.writeUTF(HTTP_RETAINED_LISTENER_MAGIC); + output.writeUTF(settings.host); + output.writeInt(settings.port); + output.writeUTF(settings.publicEndpoint); + } + Path temporary = target.resolveSibling(target.getFileName() + "." + UUID.randomUUID() + ".tmp"); + try { + Files.write(temporary, encoded.toByteArray(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + DurableFiles.publishStagedFile(temporary, target); + } finally { + Files.deleteIfExists(temporary); + } + } + + private HttpListenerSettings loadRetainedHttpListenerSettingsUnchecked() { + try { + return loadRetainedHttpListenerSettings(); + } catch (IOException failure) { + throw new IllegalStateException("Retained HTTP listener settings could not be read", failure); + } + } + + private HttpListenerSettings loadRetainedHttpListenerSettings() throws IOException { + Path source = retainedHttpListenerSettingsPath(); + if (!Files.isRegularFile(source)) return null; + try (DataInputStream input = new DataInputStream(Files.newInputStream(source))) { + if (!HTTP_RETAINED_LISTENER_MAGIC.equals(input.readUTF())) { + throw new IOException("Unsupported retained HTTP listener settings"); + } + String host = input.readUTF(); + int port = input.readInt(); + String publicEndpoint = input.readUTF(); + if (input.read() != -1 || host.isBlank() || port < 1 || port > 65535) { + throw new IOException("Invalid retained HTTP listener settings"); + } + try { + validatedHttpEndpoint(publicEndpoint); + } catch (IllegalArgumentException invalid) { + throw new IOException("Invalid retained HTTP public endpoint", invalid); + } + return new HttpListenerSettings(host, port, publicEndpoint); + } + } + + private void clearRetainedHttpListenerSettings() { + try { + DurableFiles.deleteIfExists(retainedHttpListenerSettingsPath()); + } catch (IOException failure) { + logSevere("Unable to remove obsolete retained HTTP listener settings: " + failure.getMessage()); + } + } + + private boolean hasChangedLiveHttpConfiguration() { + return httpTransportServer != null && (!java.util.Objects.equals(liveHttpHost, getConfig().getHttpHost()) + || liveHttpPort != getConfig().getHttpPort() + || !java.util.Objects.equals(liveHttpPublicEndpoint, getConfig().getHttpPublicEndpoint())); + } + + /** Uses SimpleAPI #80 when deployed while remaining safe with an older published snapshot. */ + protected boolean httpTransportHasPendingDeliveries(HttpProxyTransportServer transport) { + try { + return Boolean.TRUE.equals(transport.getClass().getMethod("hasPendingDeliveries").invoke(transport)); + } catch (NoSuchMethodException unavailable) { + // Without an authoritative query, the live durable server cannot be proven empty. + return true; + } catch (ReflectiveOperationException | SecurityException failure) { + logSevere("Retaining HTTP transport because its live delivery queue could not be inspected"); + return true; + } + } + + /** Invokes the additive SimpleAPI disk probe, with an equivalent bounded compatibility fallback. */ + protected boolean httpQueueHasPersistedDeliveries(java.nio.file.Path outgoingDirectory) throws IOException { + try { + Object result = HttpProxyTransportServer.class.getMethod("hasPersistedDeliveries", java.nio.file.Path.class) + .invoke(null, outgoingDirectory); + return Boolean.TRUE.equals(result); + } catch (NoSuchMethodException unavailable) { + return inspectPersistedHttpDeliveries(outgoingDirectory); + } catch (java.lang.reflect.InvocationTargetException failure) { + Throwable cause = failure.getCause(); + if (cause instanceof IOException io) throw io; + throw new IOException("HTTP outgoing queue inspection failed", cause); + } catch (ReflectiveOperationException | SecurityException failure) { + throw new IOException("HTTP outgoing queue inspection failed", failure); + } + } + + private boolean inspectPersistedHttpDeliveries(java.nio.file.Path outgoingDirectory) throws IOException { + java.nio.file.Path root = outgoingDirectory.toAbsolutePath().normalize(); + if (!java.nio.file.Files.exists(root, java.nio.file.LinkOption.NOFOLLOW_LINKS)) return false; + if (java.nio.file.Files.isSymbolicLink(root) + || !java.nio.file.Files.isDirectory(root, java.nio.file.LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP outgoing queue directory is invalid"); + int backendCount = 0; + try (java.nio.file.DirectoryStream backends = java.nio.file.Files.newDirectoryStream(root)) { + for (java.nio.file.Path backend : backends) { + if (java.nio.file.Files.isSymbolicLink(backend) + || !java.nio.file.Files.isDirectory(backend, java.nio.file.LinkOption.NOFOLLOW_LINKS)) + throw new IOException("HTTP outgoing queue contains an invalid entry"); + if (++backendCount > 128) throw new IOException("HTTP outgoing queue exceeds its backend bound"); + try (java.nio.file.DirectoryStream entries = + java.nio.file.Files.newDirectoryStream(backend)) { + if (entries.iterator().hasNext()) return true; + } + } + } + return false; + } + + private boolean hasPendingCachedHttpDeliveries() { + VoteCacheHandler cache = getVoteCacheHandler(); + if (cache == null) return false; + String[] cachedServers = cache.getCachedVotesServers(); + if (cachedServers != null) { + for (String server : cachedServers) { + Collection votes = cache.getVotes(server); + if (hasPendingHttpDelivery(votes)) return true; + } + } + Collection onlinePlayers = cache.getOnlineVoteUUIDs(); + if (onlinePlayers != null) { + for (String uuid : onlinePlayers) { + Collection votes = cache.getOnlineVotes(uuid); + if (hasPendingHttpDelivery(votes)) return true; + } + } + Collection timedVotes = cache.getTimeChangeQueue(); + if (timedVotes != null) { + for (VoteTimeQueue vote : timedVotes) { + if (vote != null && vote.hasPendingHttpBroadcastDeliveryIds()) return true; + } + } + return false; + } + + private static boolean hasPendingHttpDelivery(Collection votes) { + if (votes == null) return false; + for (OfflineBungeeVote vote : votes) { + if (vote != null && vote.hasPendingHttpDeliveryIds()) return true; + } + return false; + } + private synchronized void rebuildSocketClients() { HashMap rebuilt = new HashMap<>(); try { @@ -2653,6 +3743,384 @@ private synchronized boolean sendSocketEnvelope(String server, JsonEnvelope enve } } + protected synchronized boolean sendHttpEnvelope(String server, JsonEnvelope envelope) { + HttpProxyTransportServer transport = httpTransportServer; + return transport != null && transport.send(server, envelope); + } + + /** + * Sends a vote envelope while recovering the stable ID exposed when durable + * publication is indeterminate. The first attempt has already published a + * quarantine file, so retrying the identical envelope with a new ID could + * deliver the vote twice. + */ + protected boolean sendHttpEnvelopeWithRecovery(String server, JsonEnvelope envelope) { + return sendHttpEnvelopeWithRecovery(server, envelope, null); + } + + /** + * Sends a non-reward HTTP message without allowing a transport handoff failure + * to retry the surrounding vote transaction. An ambiguous durable publication + * is recovered with its original ID; a definite rejection is reported and + * logged, rather than escaping through the void global-message adapter. + */ + protected boolean sendGenericHttpEnvelope(String server, JsonEnvelope envelope) { + try { + boolean accepted = sendHttpEnvelopeWithRecovery(server, envelope); + if (!accepted) debug("HTTP transport rejected auxiliary delivery for " + server); + return accepted; + } catch (RuntimeException failure) { + debug("Unable to send HTTP auxiliary delivery: " + failure.getMessage()); + return false; + } + } + + protected boolean sendStableHttpEnvelope(String server, String deliveryId, JsonEnvelope envelope) { + try { + boolean accepted = sendHttpEnvelope(server, deliveryId, envelope); + if (!accepted) debug("HTTP transport rejected auxiliary delivery for " + server); + return accepted; + } catch (RuntimeException failure) { + debug("Unable to send HTTP auxiliary delivery: " + failure.getMessage()); + return false; + } + } + + protected boolean sendHttpEnvelopeWithRecovery(String server, JsonEnvelope envelope, OfflineBungeeVote cachedVote) { + String persistedId = cachedVote == null ? null : cachedVote.getHttpDeliveryId(server); + String stableId = persistedId != null ? persistedId + : stableCachedHttpDeliveryId("reward", server, envelope, cachedVote); + try { + boolean accepted = stableId == null ? sendHttpEnvelope(server, envelope) + : sendHttpEnvelope(server, stableId, envelope); + if (accepted && persistedId != null) { + cachedVote.setHttpDeliveryId(server, null); + cachedVote.setDeliveryStateDirty(true); + } + return accepted; + } catch (HttpProxyTransportServer.DeliveryRetryException failure) { + if (cachedVote != null) { + cachedVote.setHttpDeliveryId(server, failure.deliveryId()); + cachedVote.setDeliveryStateDirty(true); + // Retry only after the caller durably records the recovered transport ID. + return false; + } + try { + boolean accepted = sendHttpEnvelope(server, failure.deliveryId(), envelope); + return accepted; + } catch (RuntimeException retryFailure) { + debug("Unable to recover HTTP vote delivery " + failure.deliveryId() + ": " + retryFailure.getMessage()); + return false; + } + } + } + + /** Derives the same transport identity after a crash before cache cleanup. */ + private String stableCachedHttpDeliveryId(String purpose, String server, JsonEnvelope envelope, + OfflineBungeeVote cachedVote) { + if (cachedVote == null || server == null || envelope == null) return null; + String voteIdentity = cachedVote.getVoteId() == null ? stableCachedVoteRowIdentity(cachedVote) + : cachedVote.getVoteId().toString(); + String key = "VotingPlugin:http-cache:v1\u0000" + purpose + "\u0000" + + server.toLowerCase(Locale.ROOT) + "\u0000" + envelope.getSubChannel() + "\u0000" + voteIdentity; + return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + } + + /** Keeps a live vote's auxiliary broadcast idempotent across listener retries. */ + private String stableLiveHttpBroadcastDeliveryId(UUID voteId, String server) { + String key = "VotingPlugin:http-live-broadcast:v1\u0000" + voteId + "\u0000" + + server.toLowerCase(Locale.ROOT); + return UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + } + + /** + * Distinguishes pre-vote-ID cache rows that can otherwise share every visible + * vote field. SQL primary keys and JSON entry keys are durable before this + * fallback is used; the tuple remains only for callers holding an unbound + * legacy object during a mixed-version transition. + */ + private String stableCachedVoteRowIdentity(OfflineBungeeVote vote) { + if (vote.getServerVoteCacheRowId() > 0) return "server-sql:" + vote.getServerVoteCacheRowId(); + if (vote.getOnlineVoteCacheRowId() > 0) return "online-sql:" + vote.getOnlineVoteCacheRowId(); + if (vote.getServerVoteCacheJsonKey() != null) return "server-json:" + vote.getServerVoteCacheJsonKey(); + if (vote.getOnlineVoteCacheJsonKey() != null) { + return "online-json:" + vote.getUuid() + ":" + vote.getOnlineVoteCacheJsonKey(); + } + return "legacy:" + vote.getUuid() + "\u0000" + vote.getService() + "\u0000" + vote.getTime(); + } + + protected synchronized boolean sendHttpEnvelope(String server, String deliveryId, JsonEnvelope envelope) { + HttpProxyTransportServer transport = httpTransportServer; + return transport != null && transport.send(server, deliveryId, envelope); + } + + private void startHttpTransport() { + try { + HttpListenerSettings startup = deferredHttpTransportReconciliation + ? retainedHttpStartupSettings : null; + if (startup == null) { + startup = new HttpListenerSettings(getConfig().getHttpHost(), getConfig().getHttpPort(), + getConfig().getHttpPublicEndpoint()); + } + PreparedHttpTransport prepared = PREPARED_HTTP_TRANSPORTS.remove(httpTransportPreparationKey()); + if (prepared != null && retainedHttpStartupSettings == null) { + if (!prepared.matches(getConfig())) { + prepared.close(); + throw new IllegalStateException("Prepared HTTP transport does not match the installed configuration"); + } + httpTransportServer = prepared.server; + httpEnrollmentAuthority = prepared.authority; + prepared.owner.set(this); + startup = new HttpListenerSettings(prepared.host, prepared.port, prepared.publicEndpoint); + } else { + if (prepared != null) prepared.close(); + URI endpoint = validatedHttpEndpoint(startup.publicEndpoint); + File directory = new File(getDataFolderPlugin(), "http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.toPath(), endpoint.getHost()); + httpEnrollmentAuthority = new HttpEnrollmentAuthority(identity, directory.toPath()); + httpTransportServer = new HttpProxyTransportServer( + new InetSocketAddress(startup.host, startup.port), identity, + httpEnrollmentAuthority, directory.toPath().resolve("outgoing-v1"), + this::handleHttpTransportEnvelope, this::acknowledgeHttpDelivery); + httpTransportServer.start(); + } + persistRetainedHttpListenerSettings(startup); + liveHttpHost = startup.host; + liveHttpPort = startup.port; + liveHttpPublicEndpoint = startup.publicEndpoint; + retainedHttpStartupSettings = null; + logInfo("HTTP transport listening securely on " + liveHttpHost + ":" + + httpTransportServer.port() + "; use /votingpluginproxy httpcode for each backend"); + } catch (Exception failure) { + closeHttpTransport(); + throw new IllegalStateException("HTTP transport could not start securely", failure); + } + } + + /** Keeps the authenticated mTLS backend identity attached to security-sensitive proxy routing. */ + protected void handleHttpTransportEnvelope(HttpProxyTransportServer.ReceivedEnvelope received) { + if (!isAuthenticatedHttpEnvelopeAllowed(received)) { + debug("Ignored HTTP envelope whose player-presence claim did not match its authenticated backend"); + return; + } + GlobalMessageProxyHandler handler = globalMessageProxyHandler; + if (handler == null) throw new IllegalStateException("HTTP message router is not ready"); + handler.onMessage(received.envelope()); + } + + private boolean isAuthenticatedHttpEnvelopeAllowed(HttpProxyTransportServer.ReceivedEnvelope received) { + if (received == null || received.envelope() == null || received.serverId() == null) return false; + String stampedServer = received.envelope().getFields().getOrDefault(VotingPluginWire.K_SERVER, ""); + if (!received.serverId().equalsIgnoreCase(stampedServer)) return false; + if (!VotingPluginWire.SUB_LOGIN.equals(received.envelope().getSubChannel())) return true; + VotingPluginWire.PlayerPresenceEvent event = VotingPluginWire.readPlayerPresenceEvent(received.envelope()); + boolean modern = event.connectionId != null || event.backendIncarnationId != null + || event.backendStartedAt != 0L || event.presenceTimestamp != 0L; + if (!modern || isDedicatedVotingProxyEnabled()) return true; + // A player-facing proxy has a stronger authority than any backend: its live + // player connection supplies both the current route and (in online mode) UUID. + return isLegacyLoginDestinationAuthoritative(event.player, event.uuid, received.serverId()); + } + + private synchronized void closeHttpTransport() { + HttpProxyTransportServer transport = httpTransportServer; + httpTransportServer = null; + httpEnrollmentAuthority = null; + liveHttpHost = null; + liveHttpPort = 0; + liveHttpPublicEndpoint = null; + if (transport != null) transport.close(); + } + + /** + * Starts the candidate HTTP listener before Control publishes an HTTP method + * change. The replacement runtime adopts this listener, avoiding a bind/TLS/ + * queue failure after the old runtime has already been torn down. + */ + public synchronized void prepareHttpTransportChange(VotingPluginProxyConfig candidate) { + cancelPreparedHttpTransportChange(); + PreparedHttpTransport prepared = createPreparedHttpTransport(candidate); + PREPARED_HTTP_TRANSPORTS.put(httpTransportPreparationKey(), prepared); + } + + /** Returns whether the live listener already implements this HTTP snapshot. */ + public synchronized boolean hasMatchingLiveHttpTransport(VotingPluginProxyConfig candidate) { + return candidate != null && httpTransportServer != null + && java.util.Objects.equals(liveHttpHost, candidate.getHttpHost()) + && liveHttpPort == candidate.getHttpPort() + && java.util.Objects.equals(liveHttpPublicEndpoint, candidate.getHttpPublicEndpoint()); + } + + /** Returns whether a candidate would collide with the currently bound HTTP listener. */ + public synchronized boolean hasLiveHttpBind(VotingPluginProxyConfig candidate) { + return candidate != null && httpTransportServer != null + && java.util.Objects.equals(liveHttpHost, candidate.getHttpHost()) + && liveHttpPort == candidate.getHttpPort(); + } + + /** Cancels a candidate listener when configuration publication fails. */ + public synchronized void cancelPreparedHttpTransportChange() { + PreparedHttpTransport prepared = PREPARED_HTTP_TRANSPORTS.remove(httpTransportPreparationKey()); + if (prepared != null) prepared.close(); + } + + private PreparedHttpTransport createPreparedHttpTransport(VotingPluginProxyConfig candidate) { + HttpProxyTransportServer server = null; + try { + URI endpoint = validatedHttpEndpoint(candidate.getHttpPublicEndpoint()); + File directory = new File(getDataFolderPlugin(), "http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.toPath(), endpoint.getHost()); + HttpEnrollmentAuthority authority = new HttpEnrollmentAuthority(identity, directory.toPath()); + AtomicReference owner = new AtomicReference<>(); + server = new HttpProxyTransportServer( + new InetSocketAddress(candidate.getHttpHost(), candidate.getHttpPort()), identity, authority, + directory.toPath().resolve("outgoing-v1"), received -> { + VotingPluginProxy active = owner.get(); + if (active == null) throw new IllegalStateException("HTTP runtime replacement is not active"); + active.handleHttpTransportEnvelope(received); + }, (backend, deliveryId) -> { + VotingPluginProxy active = owner.get(); + if (active == null) throw new IOException("HTTP runtime replacement is not active"); + active.acknowledgeHttpDelivery(backend, deliveryId); + }); + server.start(); + return new PreparedHttpTransport(server, authority, owner, candidate.getHttpHost(), candidate.getHttpPort(), + candidate.getHttpPublicEndpoint()); + } catch (Exception failure) { + if (server != null) server.close(); + throw new IllegalStateException("HTTP transport could not be prepared securely", failure); + } + } + + private Path httpTransportPreparationKey() { + return getDataFolderPlugin().toPath().toAbsolutePath().normalize(); + } + + private URI validatedHttpEndpoint(String publicEndpoint) { + URI endpoint = URI.create(publicEndpoint); + if (!"https".equalsIgnoreCase(endpoint.getScheme()) || endpoint.getHost() == null + || endpoint.getPort() == 0 || endpoint.getPort() > 65535 + || endpoint.getUserInfo() != null || endpoint.getQuery() != null || endpoint.getFragment() != null + || (endpoint.getPath() != null && !endpoint.getPath().isEmpty() && !"/".equals(endpoint.getPath()))) { + throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); + } + return endpoint; + } + + /** + * Receives every durable HTTP acknowledgement, including non-vote-party + * deliveries. The SimpleAPI queue removes the acknowledged entry immediately + * after this callback returns, so defer inspection to the proxy scheduler. + */ + protected void acknowledgeHttpDelivery(String server, String deliveryId) throws IOException { + acknowledgeVotePartyDelivery(server, deliveryId); + scheduleDeferredHttpTransportReconciliation(); + } + + /** Rebuilds the proxy runtime only after the retained HTTP transport is proven empty. */ + private void invalidateDeferredHttpTransportReconciliation() { + synchronized (this) { + httpTransportReconciliationGeneration++; + httpTransportReconciliationScheduled = false; + // Older work is fenced by its generation and must not suppress the + // replacement runtime's own single-flight probe. + httpTransportReconciliationRunning = false; + } + } + + private void scheduleDeferredHttpTransportReconciliation() { + scheduleDeferredHttpTransportReconciliation(HTTP_TRANSPORT_RECONCILIATION_DELAY_MILLIS); + } + + private void scheduleDeferredHttpTransportReconciliation(long delayMillis) { + scheduleDeferredHttpTransportReconciliation(delayMillis, -1L); + } + + private void scheduleDeferredHttpTransportReconciliation(long delayMillis, long requiredGeneration) { + ScheduledExecutorService scheduler = getScheduler(); + if (scheduler == null) return; + long generation; + synchronized (this) { + if (requiredGeneration >= 0L && requiredGeneration != httpTransportReconciliationGeneration) return; + if (!deferredHttpTransportReconciliation || method != BungeeMethod.HTTP + || httpTransportReconciliationScheduled || httpTransportReconciliationRunning) return; + httpTransportReconciliationScheduled = true; + generation = httpTransportReconciliationGeneration; + } + try { + scheduler.schedule(() -> reconcileDeferredHttpTransport(generation), delayMillis, TimeUnit.MILLISECONDS); + } catch (RuntimeException unavailable) { + synchronized (this) { + if (generation == httpTransportReconciliationGeneration) + httpTransportReconciliationScheduled = false; + } + debug("Unable to schedule deferred HTTP transport reconciliation: " + unavailable.getMessage()); + } + } + + private void reconcileDeferredHttpTransport(long generation) { + BungeeMethod configuredMethod; + boolean stillPending; + synchronized (this) { + if (generation != httpTransportReconciliationGeneration) return; + httpTransportReconciliationScheduled = false; + if (!enabled || !deferredHttpTransportReconciliation || method != BungeeMethod.HTTP) return; + configuredMethod = BungeeMethod.getByName(getConfig().getBungeeMethod()); + if (configuredMethod == null) configuredMethod = BungeeMethod.PLUGINMESSAGING; + retainHttpForPendingDeliveries(configuredMethod); + stillPending = deferredHttpTransportReconciliation; + if (!stillPending) httpTransportReconciliationRunning = true; + } + if (stillPending) { + // The acknowledgement callback happens before queue removal. Re-arm the + // same single-flight probe so the final removal/cached-state clear is + // observed without relying on another inbound message. + scheduleDeferredHttpTransportReconciliation(HTTP_TRANSPORT_RECONCILIATION_POLL_MILLIS, generation); + return; + } + synchronized (this) { + if (generation != httpTransportReconciliationGeneration || !enabled) { + httpTransportReconciliationRunning = false; + return; + } + } + try { + // The concrete platform checks this generation again while holding its + // reload lock. That closes the gap between this scheduler callback and a + // manual platform reload/shutdown without introducing lock inversion. + reloadDeferredHttpTransportCore(generation); + } catch (RuntimeException reconciliationFailure) { + debug("Deferred HTTP transport reconciliation failed: " + reconciliationFailure.getMessage()); + } finally { + boolean retry; + synchronized (this) { + retry = generation == httpTransportReconciliationGeneration && enabled + && deferredHttpTransportReconciliation && method == BungeeMethod.HTTP; + if (generation == httpTransportReconciliationGeneration) + httpTransportReconciliationRunning = false; + } + if (retry) + scheduleDeferredHttpTransportReconciliation(HTTP_TRANSPORT_RECONCILIATION_POLL_MILLIS, generation); + } + } + + public String createHttpConnectionCode(String serverId) { + HttpEnrollmentAuthority authority = httpEnrollmentAuthority; + if (method != BungeeMethod.HTTP || authority == null) { + throw new IllegalStateException("The HTTP transport is not running"); + } + String publicEndpoint = liveHttpPublicEndpoint != null + ? liveHttpPublicEndpoint : getConfig().getHttpPublicEndpoint(); + return authority.createConnectionCode(serverId, URI.create(publicEndpoint), Duration.ofMinutes(15)) + .encode(); + } + + public void revokeHttpBackend(String serverId) { + HttpEnrollmentAuthority authority = httpEnrollmentAuthority; + if (method != BungeeMethod.HTTP || authority == null) throw new IllegalStateException("The HTTP transport is not running"); + authority.revoke(HttpTlsIdentity.canonicalServerId(serverId)); + } + private synchronized void closeSocketClients() { HashMap clients = clientHandles; clientHandles = null; @@ -2673,7 +4141,7 @@ static void stopSocketClients(Map clients) { private void warnUnsupportedDedicatedVotingProxyMode() { if (getConfig().getDedicatedVotingProxy() && (method == null || !method.supportsBackendPresence())) { - logSevere("DedicatedVotingProxy requires MYSQL, REDIS, MQTT, or SOCKETS; PLUGINMESSAGING is disabled for " + logSevere("DedicatedVotingProxy requires MYSQL, REDIS, MQTT, SOCKETS, or HTTP; PLUGINMESSAGING is disabled for " + "dedicated-proxy routing. Falling back to normal proxy routing."); } } @@ -2685,10 +4153,44 @@ private void warnUnsupportedDedicatedVotingProxyMode() { public abstract void runConsoleCommand(String command); + /** Completion boundary used before durable HTTP vote-party command progress is advanced. */ + protected CompletableFuture runVotePartyConsoleCommand(String command) { + runConsoleCommand(command); + return CompletableFuture.completedFuture(null); + } + + /** Schedules the liveness fence for a platform command whose completion is uncertain. */ + protected void scheduleVotePartyProxyCommandTimeout(Runnable timeout) { + CompletableFuture.delayedExecutor(60, TimeUnit.SECONDS).execute(timeout); + } + public abstract void saveVoteCacheFile(); public abstract void reloadCore(boolean mysql); + /** Platform implementations recheck this under their reload lock before replacing the runtime. */ + protected void reloadDeferredHttpTransportCore(long generation) { + if (isDeferredHttpTransportGenerationCurrent(generation)) reloadCore(true); + } + + protected synchronized boolean isDeferredHttpTransportGenerationCurrent(long generation) { + return enabled && generation == httpTransportReconciliationGeneration + && httpTransportReconciliationRunning && method == BungeeMethod.HTTP; + } + + /** True when a changed configured transport must not retire this runtime's live HTTP queue yet. */ + public synchronized boolean isRetainingHttpTransportForDeferredReconciliation() { + return deferredHttpTransportReconciliation && method == BungeeMethod.HTTP; + } + + /** An active HTTP runtime changing method or endpoint needs a pre-teardown retention probe. */ + public synchronized boolean requiresHttpRetentionCheckBeforeRuntimeReplacement() { + BungeeMethod configuredMethod = BungeeMethod.getByName(getConfig().getBungeeMethod()); + if (configuredMethod == null) configuredMethod = BungeeMethod.PLUGINMESSAGING; + return method == BungeeMethod.HTTP + && (configuredMethod != BungeeMethod.HTTP || hasChangedLiveHttpConfiguration()); + } + /** Strict Control reload path; failures propagate so the caller can restore its backup. */ public abstract void reloadControlConfiguration() throws Exception; @@ -2856,9 +4358,303 @@ public void sendServerNameMessage() { } } - public void sendVoteParty(String server) { - if (isSomeoneOnlineServerForVoteRouting(server)) { + public synchronized void sendVoteParty(String server) { + if (!isSomeoneOnlineServerForVoteRouting(server)) return; + if (method != BungeeMethod.HTTP) { globalMessageProxyHandler.sendMessage(server, 1, VotingPluginWire.votePartyBungee()); + return; + } + Collection pending = getVoteCachePendingVotePartyRewardIds(server); + if (pending != null && pending.size() >= MAX_PENDING_VOTE_PARTY_REWARDS) { + logSevere("HTTP vote-party reward backlog is full for " + server); + return; + } + String deliveryId = UUID.randomUUID().toString(); + // Persist intent before the bounded HTTP queue is attempted. A rejection or + // restart therefore leaves a retryable reward instead of silently losing it. + setVoteCachePendingVotePartyReward(server, deliveryId, true); + try { + saveVotePartyStateDurably(); + } catch (IOException failure) { + setVoteCachePendingVotePartyReward(server, deliveryId, false); + throw new IllegalStateException("Unable to persist HTTP vote-party reward", failure); + } + retryPendingVotePartyRewards(); + } + + protected synchronized void retryPendingVotePartyRewards() { + if (!enabled || method != BungeeMethod.HTTP) return; + boolean retryRequired = false; + Collection servers = getVoteCachePendingVotePartyServers(); + if (servers == null) return; + for (String server : new ArrayList<>(servers)) { + Collection pendingIds = getVoteCachePendingVotePartyRewardIds(server); + if (pendingIds == null || pendingIds.isEmpty()) continue; + String routingServer = resolveVotePartyRoutingServer(server); + for (String deliveryId : new ArrayList<>(pendingIds)) { + if (!isSomeoneOnlineServerForVoteRouting(routingServer) + || !sendHttpEnvelope(routingServer, deliveryId, VotingPluginWire.votePartyBungee())) { + retryRequired = true; + break; + } + retryRequired = true; + } + } + if (retryRequired) scheduleVotePartyDeliveryRetry(); + } + + protected synchronized boolean retryPendingVotePartyProxyEffects() { + if (!enabled) return true; + if (votePartyProxyCommandInFlight != 0L) return false; + if (votePartyProxyCommandCompletedUnpersisted + && !quarantineInFlightVotePartyProxyCommandForReplacement()) { + scheduleVotePartyDeliveryRetry(); + return false; + } + PendingVotePartyProxyEffects pending; + try { + pending = getVoteCachePendingVotePartyProxyEffects(); + } catch (RuntimeException invalid) { + logSevere("Pending HTTP vote-party proxy effects are invalid; retaining them without execution"); + return false; + } + while (!pending.isEmpty()) { + PendingVotePartyProxyEffects remaining; + try { + if (!pending.broadcast().isEmpty()) { + broadcast(pending.broadcast()); + remaining = new PendingVotePartyProxyEffects("", pending.commands()); + } else { + CompletableFuture execution = runVotePartyConsoleCommand(pending.commands().get(0)); + remaining = new PendingVotePartyProxyEffects("", pending.commands().subList(1, pending.commands().size())); + if (!execution.isDone()) { + long attempt = ++votePartyProxyCommandAttemptSequence; + if (attempt == 0L) attempt = ++votePartyProxyCommandAttemptSequence; + long commandAttempt = attempt; + votePartyProxyCommandInFlight = commandAttempt; + votePartyProxyCommandExecution = execution; + votePartyProxyCommandCompletedUnpersisted = false; + PendingVotePartyProxyEffects expected = pending; + PendingVotePartyProxyEffects completed = remaining; + execution.whenComplete((ignored, failure) -> + completeVotePartyProxyCommand(commandAttempt, expected, completed, failure)); + long scheduledAttempt = commandAttempt; + try { + scheduleVotePartyProxyCommandTimeout(() -> + quarantineTimedOutVotePartyProxyCommand(scheduledAttempt, expected, completed, execution)); + } catch (RuntimeException unavailable) { + logSevere("Unable to schedule the HTTP vote-party command liveness fence; the command remains pending"); + } + return false; + } + execution.join(); + } + } catch (RuntimeException failure) { + logSevere("A committed HTTP vote-party proxy effect failed and remains pending for retry"); + scheduleVotePartyDeliveryRetry(); + return false; + } + + if (!persistVotePartyProxyEffectProgress(pending, remaining)) return false; + pending = remaining; + } + return true; + } + + private void completeVotePartyProxyCommand(long attempt, PendingVotePartyProxyEffects expected, + PendingVotePartyProxyEffects remaining, Throwable failure) { + synchronized (this) { + if (votePartyProxyCommandInFlight != attempt) return; + votePartyProxyCommandInFlight = 0L; + votePartyProxyCommandExecution = null; + if (failure != null) { + logSevere("A committed HTTP vote-party proxy command failed and remains pending for retry"); + if (enabled) scheduleVotePartyDeliveryRetry(); + return; + } + PendingVotePartyProxyEffects current; + try { + current = getVoteCachePendingVotePartyProxyEffects(); + } catch (RuntimeException invalid) { + logSevere("Pending HTTP vote-party proxy effects became invalid while a command was running"); + return; + } + if (!current.equals(expected)) { + logSevere("Pending HTTP vote-party proxy effects changed while a command was running; progress was not advanced"); + return; + } + if (!persistVotePartyProxyEffectProgress(expected, remaining)) { + votePartyProxyCommandCompletedUnpersisted = true; + if (!enabled) quarantineInFlightVotePartyProxyCommandForReplacement(); + return; + } + votePartyProxyCommandCompletedUnpersisted = false; + if (enabled && retryPendingVotePartyProxyEffects()) { + if (method == BungeeMethod.HTTP) retryPendingVotePartyRewards(); + if (votePartyVotes >= currentVotePartyVotesRequired) checkVoteParty(); + } + } + } + + private void quarantineTimedOutVotePartyProxyCommand(long attempt, PendingVotePartyProxyEffects expected, + PendingVotePartyProxyEffects remaining, CompletableFuture execution) { + synchronized (this) { + if (!enabled || execution.isDone() || votePartyProxyCommandInFlight != attempt) return; + PendingVotePartyProxyEffects current; + PendingVotePartyProxyEffects previousQuarantine; + try { + current = getVoteCachePendingVotePartyProxyEffects(); + previousQuarantine = getVoteCacheQuarantinedVotePartyProxyEffects(); + } catch (RuntimeException invalid) { + logSevere("Pending HTTP vote-party proxy effects became invalid while a command was running"); + return; + } + if (!current.equals(expected)) { + logSevere("Pending HTTP vote-party proxy effects changed while a command was running; the attempt remains fenced"); + return; + } + PendingVotePartyProxyEffects quarantine; + try { + java.util.List commands = new java.util.ArrayList<>(previousQuarantine.commands()); + commands.add(expected.commands().get(0)); + quarantine = new PendingVotePartyProxyEffects(previousQuarantine.broadcast(), commands); + } catch (RuntimeException full) { + logSevere("HTTP vote-party command quarantine is full; the uncertain command remains fenced"); + return; + } + setVoteCacheQuarantinedVotePartyProxyEffects(quarantine); + setVoteCachePendingVotePartyProxyEffects(remaining); + try { + saveVotePartyStateDurably(); + } catch (IOException | RuntimeException failure) { + setVoteCacheQuarantinedVotePartyProxyEffects(previousQuarantine); + setVoteCachePendingVotePartyProxyEffects(expected); + logSevere("Unable to durably quarantine an uncertain HTTP vote-party command; the attempt remains fenced"); + return; + } + votePartyProxyCommandInFlight = 0L; + votePartyProxyCommandExecution = null; + logSevere("An HTTP vote-party proxy command did not complete within 60 seconds and was durably quarantined without retry"); + if (retryPendingVotePartyProxyEffects()) { + if (method == BungeeMethod.HTTP) retryPendingVotePartyRewards(); + if (votePartyVotes >= currentVotePartyVotesRequired) checkVoteParty(); + } + } + } + + protected synchronized boolean quarantineInFlightVotePartyProxyCommandForReplacement() { + if (votePartyProxyCommandInFlight == 0L && !votePartyProxyCommandCompletedUnpersisted) return true; + PendingVotePartyProxyEffects pending; + PendingVotePartyProxyEffects previousQuarantine; + try { + pending = getVoteCachePendingVotePartyProxyEffects(); + previousQuarantine = getVoteCacheQuarantinedVotePartyProxyEffects(); + if (pending.commands().isEmpty()) return false; + java.util.List commands = new java.util.ArrayList<>(previousQuarantine.commands()); + commands.add(pending.commands().get(0)); + PendingVotePartyProxyEffects quarantine = new PendingVotePartyProxyEffects(previousQuarantine.broadcast(), commands); + PendingVotePartyProxyEffects remaining = new PendingVotePartyProxyEffects("", + pending.commands().subList(1, pending.commands().size())); + setVoteCacheQuarantinedVotePartyProxyEffects(quarantine); + setVoteCachePendingVotePartyProxyEffects(remaining); + try { + saveVotePartyStateDurably(); + } catch (IOException | RuntimeException failure) { + setVoteCacheQuarantinedVotePartyProxyEffects(previousQuarantine); + setVoteCachePendingVotePartyProxyEffects(pending); + return false; + } + votePartyProxyCommandInFlight = 0L; + votePartyProxyCommandExecution = null; + votePartyProxyCommandCompletedUnpersisted = false; + logSevere("An in-flight HTTP vote-party proxy command was durably quarantined for runtime replacement"); + return true; + } catch (RuntimeException invalid) { + return false; + } + } + + /** Gives an executing platform command a bounded chance to finish and persist progress before final teardown. */ + protected void awaitInFlightVotePartyProxyCommand() { + CompletableFuture execution = votePartyProxyCommandExecution; + if (execution == null || execution.isDone()) return; + try { + execution.get(5, TimeUnit.SECONDS); + } catch (java.util.concurrent.ExecutionException ignored) { + // The completion callback retains failed commands for retry. + } catch (java.util.concurrent.TimeoutException timeout) { + logSevere("Timed out waiting for an in-flight HTTP vote-party command during final shutdown"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + logSevere("Interrupted while waiting for an in-flight HTTP vote-party command during final shutdown"); + } + } + + private boolean persistVotePartyProxyEffectProgress(PendingVotePartyProxyEffects previous, + PendingVotePartyProxyEffects remaining) { + setVoteCachePendingVotePartyProxyEffects(remaining); + try { + saveVotePartyStateDurably(); + return true; + } catch (IOException | RuntimeException failure) { + // Execution succeeded, but its progress was not durably confirmed. Restoring + // the marker gives at-least-once recovery rather than silently skipping it. + setVoteCachePendingVotePartyProxyEffects(previous); + logSevere("Unable to persist HTTP vote-party proxy-effect progress; the effect remains pending"); + scheduleVotePartyDeliveryRetry(); + return false; + } + } + + private String resolveVotePartyRoutingServer(String canonicalServer) { + for (String configuredServer : getAllAvailableServers()) { + if (configuredServer.equalsIgnoreCase(canonicalServer)) return configuredServer; + } + return canonicalServer; + } + + protected synchronized void acknowledgeVotePartyDelivery(String server, String deliveryId) throws IOException { + Collection pendingServers = getVoteCachePendingVotePartyServers(); + if (pendingServers == null) return; + for (String pendingServer : new ArrayList<>(pendingServers)) { + if (!pendingServer.equalsIgnoreCase(server)) continue; + Collection pending = getVoteCachePendingVotePartyRewardIds(pendingServer); + if (pending == null || !pending.contains(deliveryId)) return; + setVoteCachePendingVotePartyReward(pendingServer, deliveryId, false); + try { + saveVotePartyStateDurably(); + } catch (IOException | RuntimeException failure) { + setVoteCachePendingVotePartyReward(pendingServer, deliveryId, true); + if (failure instanceof IOException ioFailure) throw ioFailure; + throw (RuntimeException) failure; + } + return; + } + } + + private void scheduleVotePartyDeliveryRetry() { + if (!enabled || votePartyDeliveryRetryScheduled || getScheduler() == null) return; + boolean pendingProxyEffects; + try { + pendingProxyEffects = !getVoteCachePendingVotePartyProxyEffects().isEmpty(); + } catch (RuntimeException invalid) { + logSevere("Pending HTTP vote-party proxy effects are invalid; automatic execution is disabled"); + return; + } + Collection pendingServers = method == BungeeMethod.HTTP ? getVoteCachePendingVotePartyServers() : null; + if (!pendingProxyEffects && (pendingServers == null || pendingServers.isEmpty())) return; + votePartyDeliveryRetryScheduled = true; + try { + getScheduler().schedule(() -> { + synchronized (VotingPluginProxy.this) { votePartyDeliveryRetryScheduled = false; } + if (retryPendingVotePartyProxyEffects()) { + if (method == BungeeMethod.HTTP) retryPendingVotePartyRewards(); + if (votePartyVotes >= currentVotePartyVotesRequired) checkVoteParty(); + } + }, 5, TimeUnit.SECONDS); + } catch (RuntimeException failure) { + votePartyDeliveryRetryScheduled = false; + debug("Unable to schedule HTTP vote-party reward retry: " + failure.getMessage()); } } @@ -2882,6 +4678,12 @@ public void setCurrentVotePartyVotes(int amount) { public abstract void setVoteCacheVotePartyIncreaseVotesRequired(int votes); + public abstract void setVoteCachePendingVotePartyReward(String server, String deliveryId, boolean pending); + + public abstract void setVoteCachePendingVotePartyProxyEffects(PendingVotePartyProxyEffects effects); + + public abstract void setVoteCacheQuarantinedVotePartyProxyEffects(PendingVotePartyProxyEffects effects); + public void status() { for (String s : getAllAvailableServers()) { if (!isSomeoneOnlineServerForVoteRouting(s)) { @@ -2987,15 +4789,21 @@ private static CommunicationTestResult failure(String server, BungeeMethod metho private record PendingCommunicationTest(String server, BungeeMethod method, long startedAtNanos, CompletableFuture result) { } - private void sendVoteDelayRejected(String player, String uuid, String service, boolean playerOnline, - String playerServer) { + private boolean sendVoteDelayRejected(UUID voteId, String player, String uuid, String service, + boolean playerOnline, String playerServer) { if (!playerOnline || playerServer == null || !getAllAvailableServers().contains(playerServer)) { debug("Not sending vote delay rejection for " + player + " because the player is offline"); - return; + return true; } - globalMessageProxyHandler.sendMessage(playerServer, 1, - VotingPluginWire.voteDelayRejected(player, uuid, service, true)); + JsonEnvelope envelope = VotingPluginWire.voteDelayRejected(player, uuid, service, true); + if (method == BungeeMethod.HTTP) { + String key = voteId + "\u0000vote-delay-rejected\u0000" + playerServer.toLowerCase(Locale.ROOT); + String deliveryId = UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString(); + return sendStableHttpEnvelope(playerServer, deliveryId, envelope); + } + globalMessageProxyHandler.sendMessage(playerServer, 1, envelope); + return true; } public String getWaitUntilDelaySiteFromService(String service) { @@ -3123,16 +4931,42 @@ public boolean checkVoteDelay(String uuid, String player, String service, ArrayL public synchronized void vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid) { - vote(player, service, realVote, timeQueue, queueTime, text, uuid, null); + UUID voteId = UUID.randomUUID(); + if (vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, voteId) == QueuedVoteResult.RETRY) { + liveVoteRetries.remove(voteId); + throw new VoteRetryException(); + } + } + + public synchronized void vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId) { + if (vote(player, service, realVote, timeQueue, queueTime, text, uuid, null, voteId) == QueuedVoteResult.RETRY) { + throw new VoteRetryException(); + } + } + + /** Releases retry-only state after the bounded event-listener retries are exhausted. */ + public synchronized void abandonLiveVoteRetry(UUID voteId) { + if (voteId != null) liveVoteRetries.remove(voteId); } - private enum QueuedVoteResult { - SUCCESS, RETRY, TERMINAL + enum QueuedVoteResult { + SUCCESS, RETRY, RETRY_NONBLOCKING, TERMINAL + } + + protected QueuedVoteResult replayQueuedVote(VoteTimeQueue vote, VoteTotalsSnapshot totals, boolean realVote) { + return vote(vote.getName(), vote.getService(), realVote, false, vote.getTime(), totals, vote.getUuid(), vote); } private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote) { + return vote(player, service, realVote, timeQueue, queueTime, text, uuid, queuedVote, null); + } + + private synchronized QueuedVoteResult vote(String player, String service, boolean realVote, boolean timeQueue, + long queueTime, VoteTotalsSnapshot text, String uuid, VoteTimeQueue queuedVote, UUID requestedVoteId) { try { + String requestPlayer = player; if (!ServiceSiteValidator.isValid(service)) { warn("Rejected vote with invalid service site '" + ServiceSiteValidator.sanitizeForLog(service) + "'"); return QueuedVoteResult.TERMINAL; @@ -3143,11 +4977,28 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea + MinecraftUsernameValidator.sanitizeForLog(service) + "'"); return QueuedVoteResult.TERMINAL; } + String requestIdentity = player.toLowerCase(Locale.ROOT) + "\u0000" + service.toLowerCase(Locale.ROOT); + // A platform listener can receive a vote in the small interval after the + // replacement gate succeeds and before the platform publishes its fresh + // runtime. Tell its bounded retry wrapper to retry against that fresh + // runtime rather than creating state that would be lost with this one. + if (runtimeReplacementPrepared) return QueuedVoteResult.RETRY; UUID voteId = queuedVote == null ? null : queuedVote.getVoteId(); if (voteId == null) { - voteId = UUID.randomUUID(); + voteId = requestedVoteId == null + ? queuedVote == null ? UUID.randomUUID() : legacyTimedVoteId(queuedVote) + : requestedVoteId; + if (queuedVote != null && !getVoteCacheHandler().assignLegacyTimeVoteId(queuedVote, voteId)) { + warn("Unable to assign a stable ID to a legacy timed vote; retaining it for retry"); + return QueuedVoteResult.RETRY; + } } + LiveVoteRetryState retryState = liveVoteRetries.get(voteId); + if (retryState != null && !requestIdentity.equals(retryState.requestIdentity)) { + throw new IllegalArgumentException("Retry ID does not match the original vote"); + } + boolean resumingAfterTotals = retryState != null; // UUID resolution if (!getConfig().getOnlineMode()) { @@ -3215,24 +5066,29 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea // Cache online state/server once (IMPORTANT for broadcast logic correctness) final boolean playerOnline = isPlayerOnlineForVoteRouting(player); final String playerServer = playerOnline ? getCurrentPlayerServerForVoteRouting(player) : null; - long time = queueTime != 0 ? queueTime - : LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + long time = retryState != null ? retryState.time + : (queueTime != 0 ? queueTime + : LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()); Set broadcastTargets = queuedVote == null ? new LinkedHashSet<>() : new LinkedHashSet<>(queuedVote.getBroadcastTargets()); Set broadcastForwardedServers = queuedVote == null ? new LinkedHashSet<>() : new LinkedHashSet<>(queuedVote.getBroadcastForwardedServers()); + if (retryState != null) { + broadcastForwardedServers.addAll(retryState.broadcastForwardedServers); + } boolean proxyBroadcastHandled = queuedVote != null && queuedVote.isProxyBroadcastHandled(); boolean processesTotals = getConfig().getPrimaryServer() || !getConfig().getMultiProxySupport(); boolean managesTotals = processesTotals && getConfig().getBungeeManageTotals(); boolean canValidateStandaloneBroadcast = canForwardStandaloneBroadcast(managesTotals); - ArrayList data = null; + ArrayList data = retryState == null ? null : retryState.totalsInput; boolean queueForTimeChange = false; // A completion callback can wipe totals and replay older queued votes. Run it // before loading this vote's database snapshot so the calculations below use // the post-rollover state. - if (getConfig().getGlobalDataEnabled() && getGlobalDataHandler().isTimeChangedHappened()) { + if (!resumingAfterTotals && getConfig().getGlobalDataEnabled() + && getGlobalDataHandler().isTimeChangedHappened()) { getGlobalDataHandler().checkForFinishedTimeChanges(); queueForTimeChange = timeQueue && getGlobalDataHandler().isTimeChangedHappened(); } @@ -3240,7 +5096,7 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea // Validate the vote before any immediate announcement. This keeps duplicate // votes rejected by the delay check out of the GlobalData rollover queue and // prevents announcing a vote that will not be processed. - if (managesTotals) { + if (!resumingAfterTotals && managesTotals) { if (getProxyMySQL() == null) { logSevere("Mysql is not loaded correctly, stopping vote processing"); return QueuedVoteResult.RETRY; @@ -3254,7 +5110,8 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea data = getProxyMySQL().getExactQuery(new Column("uuid", new DataValueString(uuid))); if (!checkVoteDelay(uuid, player, service, data, queuedVote == null)) { log("Vote delay is not met for " + player + "/" + service + ", skipping vote"); - sendVoteDelayRejected(player, uuid, service, playerOnline, playerServer); + if (!sendVoteDelayRejected(voteId, player, uuid, service, playerOnline, playerServer) + && queuedVote != null) return QueuedVoteResult.RETRY; return QueuedVoteResult.TERMINAL; } } @@ -3271,6 +5128,7 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea VoteTimeQueue delayedVote = new VoteTimeQueue(voteId, player, service, time, proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, projectedTotals == null ? "" : projectedTotals.toString(), false, uuid); + delayedVote.setRealVote(realVote); if (!getVoteCacheHandler().addTimeVoteToCache(delayedVote)) { logSevere("Unable to persist queued rollover vote for " + player + "/" + service + "; skipping proxy broadcast"); @@ -3279,9 +5137,10 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea if (proxyBroadcastHandled) { for (String target : broadcastTargets) { Set forwarded = sendProxyBroadcast(Collections.singleton(target), uuid, player, - service, time, projectedTotals == null ? "" : projectedTotals.toString(), false); - if (delayedVote.getBroadcastForwardedServers().addAll(forwarded)) { - broadcastForwardedServers.addAll(forwarded); + service, time, projectedTotals == null ? "" : projectedTotals.toString(), false, delayedVote); + boolean newlyForwarded = delayedVote.getBroadcastForwardedServers().addAll(forwarded); + broadcastForwardedServers.addAll(forwarded); + if (newlyForwarded || delayedVote.isDeliveryStateDirty()) { persistTimeVoteDelivery(delayedVote); } } @@ -3291,10 +5150,32 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea return QueuedVoteResult.SUCCESS; } - addVoteParty(); + if (retryState == null) { + if (liveVoteRetries.size() >= MAX_LIVE_VOTE_RETRIES) return QueuedVoteResult.RETRY; + retryState = new LiveVoteRetryState(); + retryState.requestIdentity = requestIdentity; + retryState.totalsInput = data; + retryState.player = requestPlayer; + retryState.service = service; + retryState.uuid = uuid; + retryState.time = time; + retryState.realVote = realVote; + liveVoteRetries.put(voteId, retryState); + } + if (queuedVote != null) { + retryState.queuedVote = queuedVote; + retryState.multiProxyForwardingHandled |= queuedVote.isMultiProxyForwardingHandled(); + } + if (!retryState.votePartyApplied) { + // Fence the side effect before invoking it. If the call reports an + // indeterminate failure, a listener retry must not increment the party again. + retryState.votePartyApplied = true; + addVoteParty(); + } - // Totals processing (primary server OR no multiproxy) - if (processesTotals) { + if (!retryState.totalsApplied) { + // Totals processing (primary server OR no multiproxy) + if (processesTotals) { if (managesTotals) { int allTimeTotal = getValue(data, "AllTimeTotal", 1); int monthTotal = getValue(data, "MonthTotal", 1); @@ -3340,10 +5221,20 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea debug("Setting totals " + text.toString() + ", voteId=" + voteId + " for " + player + "/" + service); + retryState.totals = text; + retryState.totalsApplied = true; getProxyMySQL().update(uuid, update); } else { text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); } + } + if (text == null) { + text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); + } + retryState.totals = text; + retryState.totalsApplied = true; + } else { + text = retryState.totals; } if (text == null) { text = new VoteTotalsSnapshot(0, 0, 0, 0, 0, votePartyVotes, currentVotePartyVotesRequired, 0); @@ -3352,7 +5243,14 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea VoteLogStatus voteStatus = VoteLogStatus.IMMEDIATE; boolean standaloneProxyBroadcast = canValidateStandaloneBroadcast && (proxyBroadcastHandled || proxyBroadcastDecider.usesImmediateForwarding(playerOnline)); + if (getConfig().getSendVotesToAllServers() && retryState.rewardServers == null) { + retryState.rewardServers = new LinkedHashSet<>(getAllAvailableServers()); + } + Set rewardServers = retryState.rewardServers == null + ? Collections.emptySet() : retryState.rewardServers; Set proxyBroadcastTargets = Collections.emptySet(); + OfflineBungeeVote standaloneBroadcastState = null; + boolean standaloneBroadcastStatePersisted = false; if (standaloneProxyBroadcast) { // A handled queued broadcast was necessarily sampled while the player was // offline. Retry only targets that did not previously accept delivery. @@ -3360,15 +5258,96 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea : proxyBroadcastDecider.resolveTargets(false, null); Set remainingTargets = new LinkedHashSet<>(proxyBroadcastTargets); remainingTargets.removeAll(broadcastForwardedServers); - broadcastForwardedServers.addAll(sendProxyBroadcast(remainingTargets, uuid, player, service, time, - text == null ? "" : text.toString(), false)); + standaloneBroadcastState = retryState.standaloneBroadcastState; + if (standaloneBroadcastState == null) { + standaloneBroadcastState = new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, + text == null ? "" : text.toString(), false, true, proxyBroadcastTargets, + broadcastForwardedServers, !getConfig().getSendVotesToAllServers(), Collections.emptyMap(), queuedVote == null + ? Collections.emptyMap() : queuedVote.getHttpBroadcastDeliveryIds()); + if (getConfig().getSendVotesToAllServers()) markRewardJournalTargets(standaloneBroadcastState, rewardServers); + retryState.standaloneBroadcastState = standaloneBroadcastState; + retryState.rewardJournalOwner = standaloneBroadcastState; + } else { + proxyBroadcastTargets = new LinkedHashSet<>(standaloneBroadcastState.getBroadcastTargets()); + broadcastForwardedServers.addAll(standaloneBroadcastState.getBroadcastForwardedServers()); + remainingTargets = new LinkedHashSet<>(proxyBroadcastTargets); + remainingTargets.removeAll(broadcastForwardedServers); + } + // The canonical broadcast journal must reach durable storage before the + // first target sees the message. Otherwise a proxy crash after one accepted + // send can permanently lose every remaining target. + standaloneBroadcastStatePersisted = persistAndSendStandaloneBroadcast(uuid, + standaloneBroadcastState, remainingTargets, broadcastForwardedServers); + retryState.broadcastForwardedServers.addAll(broadcastForwardedServers); + if (!standaloneBroadcastStatePersisted) { + logSevere("Unable to durably journal standalone broadcast for " + uuid); + return QueuedVoteResult.RETRY; + } + if (queuedVote != null) { + // Keep the source row's in-memory completion state aligned with the + // canonical broadcast journal. If persisting the final processed marker + // fails, processQueue can retry that marker without publishing an already + // accepted broadcast again. + queuedVote.getBroadcastForwardedServers().addAll(broadcastForwardedServers); + for (String forwardedServer : broadcastForwardedServers) { + queuedVote.setHttpBroadcastDeliveryId(forwardedServer, null); + } + } } // =========================== // Send vote(s) to backend(s) // =========================== if (getConfig().getSendVotesToAllServers()) { - for (String s : getAllAvailableServers()) { + OfflineBungeeVote rewardJournalOwner = retryState.rewardJournalOwner; + if (rewardJournalOwner == null) { + rewardJournalOwner = createCachedRewardVote(voteId, player, uuid, service, time, realVote, + text.toString(), false); + markRewardJournalTargets(rewardJournalOwner, rewardServers); + retryState.rewardJournalOwner = rewardJournalOwner; + if (!getVoteCacheHandler().addOnlineVoteDurably(uuid, rewardJournalOwner)) { + if (getVoteCacheHandler().retainOnlineVoteForPersistenceRetry(uuid, rewardJournalOwner)) { + scheduleCachedVoteDeliveryRetry(); + } + return QueuedVoteResult.RETRY; + } + } + for (String server : rewardServers) { + if (retryState.deliveredRewardServers.contains(server)) continue; + OfflineBungeeVote rewardState = retryState.rewardStates.get(server.toLowerCase(Locale.ROOT)); + if (rewardState == null) { + rewardState = createCachedRewardVote(voteId, player, uuid, service, time, + realVote, text.toString(), standaloneProxyBroadcast); + retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), rewardState); + } + // Every target begins as pending so a crash before its send cannot lose + // the reward. Accepted deliveries are marked complete below. + rewardState.setRewardDelivered(false); + if (rewardState.getHttpDeliveryId(server) == null) { + rewardState.setHttpDeliveryId(server, rewardJournalDeliveryId(rewardJournalOwner, server)); + } + if (!getVoteCacheHandler().addServerVoteDurably(server, rewardState)) { + if (getVoteCacheHandler().retainServerVoteForPersistenceRetry(server, rewardState)) { + scheduleCachedVoteDeliveryRetry(); + } + logSevere("Unable to durably journal vote reward for " + server); + return QueuedVoteResult.RETRY; + } + } + if (!retryState.rewardJournalsDurable) { + // The source owner is removable only after every target-specific row is durable. + rewardJournalOwner.setRewardDelivered(true); + rewardJournalOwner.setDeliveryStateDirty(true); + if (!persistOnlineVoteDelivery(uuid, rewardJournalOwner)) return QueuedVoteResult.RETRY; + retryState.rewardJournalsDurable = true; + if (!rewardJournalOwner.isProxyBroadcastHandled() || rewardJournalOwner.isProxyBroadcastComplete()) { + if (!getVoteCacheHandler().tryRemoveOnlineVote(uuid, rewardJournalOwner)) { + scheduleCachedVoteDeliveryRetry(); + } + } + } + for (String s : rewardServers) { + if (retryState.deliveredRewardServers.contains(s)) continue; boolean forceCache = getConfig().getWaitForUserOnline() && (!playerOnline || playerServer == null || !playerServer.equalsIgnoreCase(s)); @@ -3379,12 +5358,6 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea if ((!isSomeoneOnlineServerForVoteRouting(s) && method.requiresPlayerOnline()) || forceCache) { voteStatus = VoteLogStatus.CACHED; - boolean broadcastForwarded = standaloneProxyBroadcast - && broadcastForwardedServers.containsAll(proxyBroadcastTargets); - getVoteCacheHandler().addServerVote(s, - new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, - text.toString(), broadcastForwarded, standaloneProxyBroadcast, - proxyBroadcastTargets, broadcastForwardedServers, false)); debug("Caching vote for " + player + " on " + service + " for " + s); } else { boolean broadcastHere = !broadcastForwardedServers.contains(s); @@ -3394,9 +5367,27 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea broadcastHere = proxyBroadcastDecider.shouldBroadcast(s, targets); } - globalMessageProxyHandler.sendMessage(s, 2, + OfflineBungeeVote pendingVote = retryState.rewardStates.get(s.toLowerCase(Locale.ROOT)); + boolean rewardAccepted = sendVoteEnvelopeAccepted(s, 2, VotingPluginWire.vote(player, uuid, service, time, true, realVote, text.toString(), - voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1)); + voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1), pendingVote); + if (!rewardAccepted) { + pendingVote.setRewardDelivered(false); + pendingVote.setDeliveryStateDirty(true); + if (!persistServerVoteDelivery(s, pendingVote)) { + logSevere("Unable to persist the rejected vote delivery for " + s); + return QueuedVoteResult.RETRY; + } + voteStatus = VoteLogStatus.CACHED; + debug("Caching vote after the transport rejected delivery for " + s); + } else { + pendingVote.setRewardDelivered(true); + retryState.deliveredRewardServers.add(s); + pendingVote.setDeliveryStateDirty(true); + if (!persistServerVoteDelivery(s, pendingVote)) return QueuedVoteResult.RETRY; + getVoteCacheHandler().removeServerVotes(s, + new ArrayList<>(Collections.singletonList(pendingVote))); + } } } } else { @@ -3412,11 +5403,46 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea broadcastHere = proxyBroadcastDecider.shouldBroadcast(server, targets); } - globalMessageProxyHandler.sendMessage(server, 1, - VotingPluginWire.voteOnline(player, uuid, service, time, true, realVote, text.toString(), - 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, + true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(), + standaloneBroadcastState.getHttpBroadcastDeliveryIds()) + : createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false); + retryState.rewardStates.put(server.toLowerCase(Locale.ROOT), pendingVote); + } + boolean rewardAccepted = retryState.deliveredRewardServers.contains(server); + if (!rewardAccepted) { + rewardAccepted = sendVoteEnvelopeAccepted(server, 1, + VotingPluginWire.voteOnline(player, uuid, service, time, true, realVote, text.toString(), + voteId, getConfig().getBungeeManageTotals(), broadcastHere, 1, 1), pendingVote); + if (rewardAccepted) retryState.deliveredRewardServers.add(server); + } + if (!rewardAccepted) { + if (standaloneBroadcastState != null) { + standaloneBroadcastState.setRewardDelivered(false); + standaloneBroadcastState.setDeliveryStateDirty(true); + if (!persistOnlineVoteDelivery(uuid, standaloneBroadcastState)) { + return QueuedVoteResult.RETRY; + } + } + if (!getVoteCacheHandler().addOnlineVoteDurably(uuid, pendingVote)) { + if (getVoteCacheHandler().retainOnlineVoteForPersistenceRetry(uuid, pendingVote)) { + scheduleCachedVoteDeliveryRetry(); + } + logSevere("Unable to durably cache the rejected online vote delivery for " + uuid); + return QueuedVoteResult.RETRY; + } + voteStatus = VoteLogStatus.CACHED; + standaloneBroadcastStatePersisted |= standaloneBroadcastState != null; + debug("Caching online vote after the transport rejected delivery for " + server); + } else if (standaloneBroadcastState != null + && standaloneBroadcastState.isProxyBroadcastComplete()) { + getVoteCacheHandler().removeOnlineVote(uuid, standaloneBroadcastState); + } - if (canValidateStandaloneBroadcast && getConfig().getProxyBroadcastEnabled() + if (rewardAccepted && canValidateStandaloneBroadcast && getConfig().getProxyBroadcastEnabled() && !standaloneProxyBroadcast) { Set targets = proxyBroadcastDecider.resolveTargets(true, playerServer); @@ -3429,26 +5455,58 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea if (getConfig().getBlockedServers().contains(targetServer)) { continue; } + if (broadcastForwardedServers.contains(targetServer)) { + continue; + } - globalMessageProxyHandler.sendMessage(targetServer, bDelay, - VotingPluginWire.voteBroadcast(uuid, player, service, time, - text == null ? "" : text.toString(), true)); + JsonEnvelope broadcast = VotingPluginWire.voteBroadcast(uuid, player, service, time, + text == null ? "" : text.toString(), true); + if (method == BungeeMethod.HTTP) { + String deliveryId = stableLiveHttpBroadcastDeliveryId(voteId, targetServer); + if (!sendStableHttpEnvelope(targetServer, deliveryId, broadcast)) { + retryState.broadcastForwardedServers.addAll(broadcastForwardedServers); + return queuedVote == null ? QueuedVoteResult.RETRY + : QueuedVoteResult.RETRY_NONBLOCKING; + } + broadcastForwardedServers.add(targetServer); + retryState.broadcastForwardedServers.add(targetServer); + } else { + globalMessageProxyHandler.sendMessage(targetServer, bDelay, broadcast); + } bDelay++; } } // multiproxy: envelope-only clear vote - if (getConfig().getMultiProxySupport() && getConfig().getMultiProxyOneGlobalReward()) { + if (rewardAccepted && getConfig().getMultiProxySupport() && getConfig().getMultiProxyOneGlobalReward()) { multiProxyHandler.sendClearVote(uuid, player); } } else { voteStatus = VoteLogStatus.CACHED; - boolean broadcastForwarded = standaloneProxyBroadcast - && broadcastForwardedServers.containsAll(proxyBroadcastTargets); - getVoteCacheHandler().addOnlineVote(uuid, - new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), - broadcastForwarded, standaloneProxyBroadcast, proxyBroadcastTargets, - broadcastForwardedServers, false)); + if (standaloneBroadcastState != null) { + standaloneBroadcastState.setRewardDelivered(false); + standaloneBroadcastState.setDeliveryStateDirty(true); + if (!persistOnlineVoteDelivery(uuid, standaloneBroadcastState)) { + return QueuedVoteResult.RETRY; + } + } + OfflineBungeeVote cachedReward = standaloneProxyBroadcast + ? new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text.toString(), false, + true, proxyBroadcastTargets, broadcastForwardedServers, false, Collections.emptyMap(), + standaloneBroadcastState.getHttpBroadcastDeliveryIds()) + : createCachedRewardVote(voteId, player, uuid, service, time, realVote, text.toString(), false); + retryState.pendingOnlineRewardState = cachedReward; + boolean cachedDurably = getVoteCacheHandler().addOnlineVoteDurably(uuid, cachedReward); + if (!cachedDurably) { + if (getVoteCacheHandler().retainOnlineVoteForPersistenceRetry(uuid, cachedReward)) { + scheduleCachedVoteDeliveryRetry(); + } + logSevere("Unable to durably cache online vote for " + uuid + + "; retaining it for persistence retry"); + return QueuedVoteResult.RETRY; + } + retryState.pendingOnlineRewardState = null; + standaloneBroadcastStatePersisted |= standaloneBroadcastState != null; debug("Caching online vote for " + player + " on " + service); } @@ -3460,6 +5518,12 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea } } + if (!persistUncachedStandaloneBroadcast(uuid, standaloneBroadcastState, + standaloneBroadcastStatePersisted)) { + logSevere("Unable to durably cache the pending standalone broadcast for " + uuid); + return QueuedVoteResult.RETRY; + } + // Vote logging if (voteLogMysqlTable != null && getConfig().getVoteLoggingEnabled()) { voteLogMysqlTable.logVote(voteId, voteStatus, service, uuid, player, time, @@ -3469,11 +5533,17 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea // =========================== // Multiproxy forwarding // =========================== - if (getConfig().getMultiProxySupport() && getConfig().getPrimaryServer()) { + if (!retryState.multiProxyForwardingHandled && getConfig().getMultiProxySupport() + && getConfig().getPrimaryServer() + // An overflowed received envelope carries its sender in this field. It + // must be acknowledged after local completion, never forwarded again. + && (queuedVote == null || queuedVote.getMultiProxyOrigin().isBlank())) { if (!getConfig().getMultiProxyOneGlobalReward()) { debug("Sending global proxy vote envelope"); - multiProxyHandler.sendMultiProxyEnvelope(VotingPluginWire.vote(player, uuid, service, time, false, - realVote, text == null ? "" : text.toString(), voteId, false, false, 1, 1)); + if (!beginMultiProxyForwarding(retryState, + queuedVote == null ? retryState.queuedVote : queuedVote, player, uuid, service, time, realVote, text)) { + return QueuedVoteResult.RETRY; + } } else { // Only send to other proxies if the player DID NOT already receive reward on a // backend @@ -3486,28 +5556,528 @@ private synchronized QueuedVoteResult vote(String player, String service, boolea if (shouldSend) { debug("Sending global proxy voteonline envelope"); - multiProxyHandler - .sendMultiProxyEnvelope(VotingPluginWire.voteOnline(player, uuid, service, time, false, - realVote, text == null ? "" : text.toString(), voteId, false, false, 1, 1)); + if (!beginMultiProxyForwarding(retryState, + queuedVote == null ? retryState.queuedVote : queuedVote, player, uuid, service, time, realVote, text)) { + return QueuedVoteResult.RETRY; + } } else { debug("Not sending global proxy message for voteonline, player already got reward"); + if (!markMultiProxyForwardingHandled(retryState, queuedVote)) return QueuedVoteResult.RETRY; } } } if (queuedVote != null) { queuedVote.setProcessed(true); - if (!getVoteCacheHandler().updateTimeVote(queuedVote)) { - warn("Unable to persist completed rollover vote " + queuedVote.getVoteId() - + "; attempting durable removal immediately"); + if (!queuedVote.getMultiProxyOrigin().isBlank()) { + // Persist the receiver's completion phase with the processed fence. A + // failed tombstone write must resume completion, never skip to deletion. + queuedVote.setMultiProxyCompletionPending(true); + } + if (!persistTimeVoteDelivery(queuedVote)) { + if (!queuedVote.getMultiProxyOrigin().isBlank()) { + // The receiver may delete its queue row only after a durable completion + // fence exists. That tombstone also makes a sender retry idempotent. + if (getVoteCacheHandler().hasMultiProxyVoteCompletion(voteId) + || getVoteCacheHandler().markMultiProxyVoteCompletedDurably(voteId)) { + acknowledgeCompletedMultiProxyVote(voteId, queuedVote.getMultiProxyOrigin()); + if (getVoteCacheHandler().removeTimeVote(queuedVote)) { + liveVoteRetries.remove(voteId); + return QueuedVoteResult.SUCCESS; + } + } + warn("Unable to persist completed forwarded vote " + queuedVote.getVoteId() + + "; retaining its receiver completion fence for retry"); + return QueuedVoteResult.RETRY; + } + // Deleting the completed source row is itself a durable completion + // record. Prefer that fallback when updating the marker is unavailable. + if (getVoteCacheHandler().removeTimeVote(queuedVote)) { + liveVoteRetries.remove(voteId); + return QueuedVoteResult.SUCCESS; + } + if (getVoteCacheHandler().markTimeVoteCompletedDurably(queuedVote)) { + liveVoteRetries.remove(voteId); + return QueuedVoteResult.SUCCESS; + } + warn("Unable to persist or remove completed rollover vote " + queuedVote.getVoteId() + + "; retaining its live completion fence for retry"); + return QueuedVoteResult.RETRY; } } + liveVoteRetries.remove(voteId); return QueuedVoteResult.SUCCESS; + } catch (IllegalArgumentException e) { + throw e; } catch (Exception e) { e.printStackTrace(); return QueuedVoteResult.RETRY; } } + private UUID legacyTimedVoteId(VoteTimeQueue vote) { + return vote.legacyTimedVoteId(); + } + + /** + * Creates/persists the sender outbox before the first publish. Redis and socket + * publish APIs are fire-and-forget, so their return value is never a delivery + * acknowledgement. The record stays until every configured peer confirms its + * own durable completion by stable vote ID. + */ + private boolean beginMultiProxyForwarding(LiveVoteRetryState retryState, VoteTimeQueue queuedVote, String player, + String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals) { + if (multiProxyHandler == null) return false; + // 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. + if (multiProxyHandler.isMultiProxyVoteCapabilityRecoveryBlocked()) return false; + Set recipients = multiProxyHandler.getMultiProxyVoteRecipients(); + Set renewingRecipients = multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal(); + Set discoveringRecipients = multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery(); + // Query discovery before advertising. A retry that reaches the durable + // deadline must classify the peer rather than sending a final handshake and + // immediately falling through to legacy; live discovery instead renews early + // with a bounded settling interval for asynchronous Redis replies. + if (!discoveringRecipients.isEmpty()) { + multiProxyHandler.renewMultiProxyVoteCapabilityDiscoveryIfDue(); + } else if (!renewingRecipients.isEmpty()) { + multiProxyHandler.renewMultiProxyVoteCapabilityIfDue(); + } + // Recording a first-observation deadline can itself fail and transition the + // handler into recovery-blocked state. Recheck before interpreting an empty + // discovery set as permission to use the lossy legacy route. + if (multiProxyHandler.isMultiProxyVoteCapabilityRecoveryBlocked()) return false; + Set configuredRecipients = multiProxyHandler.getConfiguredMultiProxyVoteRecipients(); + // Keep custom MultiProxyHandler integrations that override only the + // established recipient method source-compatible while the base handler + // learns capabilities. + if (configuredRecipients.isEmpty() && !recipients.isEmpty()) configuredRecipients.addAll(recipients); + if (configuredRecipients.isEmpty()) return true; + if (!discoveringRecipients.isEmpty()) { + // Do not publish any part of this vote while a newly configured peer can + // still complete the capability handshake. Capturing only the known peers + // now would make the later legacy fallback hard to fence without a second, + // duplicate-prone outbox. The durable peer deadline bounds this retry even + // across a proxy restart. + if (admitMultiProxyDiscoveryOutbox(retryState, queuedVote, player, uuid, service, time, realVote, totals) + != null) scheduleTimeVoteRetry(); + return false; + } + Set durableRecipients = new LinkedHashSet<>(recipients); + durableRecipients.addAll(renewingRecipients); + Set legacyRecipients = new LinkedHashSet<>(configuredRecipients); + legacyRecipients.removeAll(durableRecipients); + VoteTimeQueue outbox = queuedVote; + if (outbox != null && outbox.isMultiProxyCapabilityDiscoveryPending() + && !resolveMultiProxyDiscoveryOutbox(outbox)) return false; + if (durableRecipients.isEmpty() + && (outbox == null || !outbox.isMultiProxyForwardingRequired())) { + // 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, + 1, 1), legacyRecipients); + } + if (outbox == null) { + outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(), + Collections.emptySet(), totals == null ? "" : totals.toString(), 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)); + outbox.requireMultiProxyAcknowledgements(getConfig().getProxyServerName(), durableRecipients); + outbox.setMultiProxyLegacyPendingRecipients(legacyRecipients); + outbox.setRealVote(realVote); + outbox.setDeliveryStateDirty(true); + retryState.queuedVote = outbox; + if (outbox.getVoteId() == null || !getVoteCacheHandler().addTimeVoteToCache(outbox)) return false; + } else { + boolean alreadyQueued = false; + for (VoteTimeQueue candidate : getVoteCacheHandler().getTimeChangeQueue()) { + if (candidate != null && java.util.Objects.equals(outbox.getVoteId(), candidate.getVoteId())) { + alreadyQueued = true; + break; + } + } + if (!alreadyQueued && !getVoteCacheHandler().addTimeVoteToCache(outbox)) { + // A previous durable admission may have failed after the retry state kept + // this object in memory. Re-admit it before any publish; + // addTimeVoteToCache is idempotent for an already-durable queue entry. + return false; + } + } + if (outbox.isMultiProxyCapabilityDiscoveryPending()) { + // A crash can leave this row before classification. Resolve and persist the + // recipient split before publishing either route. + if (!resolveMultiProxyDiscoveryOutbox(outbox)) return false; + } else if (!outbox.isMultiProxyForwardingRequired()) { + outbox.requireMultiProxyAcknowledgements(getConfig().getProxyServerName(), durableRecipients); + outbox.setMultiProxyLegacyPendingRecipients(legacyRecipients); + outbox.setRealVote(realVote); + outbox.setProcessed(true); + outbox.setDeliveryStateDirty(true); + if (!persistTimeVoteDelivery(outbox)) return false; + } + if (outbox.hasCompletedMultiProxyAcknowledgements()) { + return finishMultiProxyRetirement(retryState, outbox); + } + // A successful publish only means the transport accepted the invocation. + // Keep the durable row and wait for an acknowledgement before continuing. + if (!outbox.getMultiProxyLegacyPendingRecipients().isEmpty()) { + // Never retry this legacy copy as part of the ACK outbox: a legacy peer has + // no receiver dedupe/ACK contract, while capable peers stay fully durable. + // Clear the replayable set durably before publishing. If the transport + // rejects synchronously, restore it for retry; a process crash retains the + // historical legacy at-most-once behavior instead of duplicating rewards. + Set legacyPending = new LinkedHashSet<>(outbox.getMultiProxyLegacyPendingRecipients()); + outbox.setMultiProxyLegacyPendingRecipients(Collections.emptySet()); + 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), + legacyPending)) { + outbox.setMultiProxyLegacyPendingRecipients(legacyPending); + outbox.setDeliveryStateDirty(true); + persistTimeVoteDelivery(outbox); + return false; + } + } + if (outbox.getMultiProxyRecipients().isEmpty()) { + // Discovery resolved every peer to the legacy route. There can be no ACK + // retirement phase, so retire the durable intent after the one-way copy was + // durably marked accepted. + boolean handled = markMultiProxyForwardingHandled(retryState, outbox); + if (handled) scheduleTimeVoteRetry(); + return handled; + } + if (!sendDurableMultiProxyOutbox(outbox)) return false; + scheduleTimeVoteRetry(); + return false; + } + + /** + * Persists a forwarding intent before the first discovery-induced retry. The + * local vote effects have already completed at this point; without this row a + * shutdown between the retry result and the next listener attempt loses the + * only record that the vote still needs forwarding. + */ + private VoteTimeQueue admitMultiProxyDiscoveryOutbox(LiveVoteRetryState retryState, VoteTimeQueue queuedVote, + String player, String uuid, String service, long time, boolean realVote, VoteTotalsSnapshot totals) { + VoteTimeQueue outbox = queuedVote; + if (outbox == null) { + outbox = new VoteTimeQueue(null, player, service, time, false, Collections.emptySet(), + Collections.emptySet(), totals == null ? "" : totals.toString(), true, uuid); + outbox.setVoteId(findLiveVoteId(retryState)); + if (outbox.getVoteId() == null) return null; + retryState.queuedVote = outbox; + } + outbox.requireMultiProxyAcknowledgements(getConfig().getProxyServerName(), Collections.emptySet()); + outbox.setMultiProxyCapabilityDiscoveryPending(true); + outbox.setMultiProxyLegacyPendingRecipients(Collections.emptySet()); + outbox.setRealVote(realVote); + outbox.setProcessed(true); + outbox.setDeliveryStateDirty(true); + boolean alreadyQueued = false; + for (VoteTimeQueue candidate : getVoteCacheHandler().getTimeChangeQueue()) { + if (candidate != null && java.util.Objects.equals(outbox.getVoteId(), candidate.getVoteId())) { + alreadyQueued = true; + break; + } + } + if (!alreadyQueued) return getVoteCacheHandler().addTimeVoteToCache(outbox) ? outbox : null; + return persistTimeVoteDelivery(outbox) ? outbox : null; + } + + /** Resolves a restored discovery-pending outbox and persists its recipient split before sending. */ + private boolean resolveMultiProxyDiscoveryOutbox(VoteTimeQueue outbox) { + if (multiProxyHandler == null || multiProxyHandler.isMultiProxyVoteCapabilityRecoveryBlocked()) return false; + Set recipients = multiProxyHandler.getMultiProxyVoteRecipients(); + Set renewingRecipients = multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal(); + Set discoveringRecipients = multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery(); + if (!discoveringRecipients.isEmpty()) { + multiProxyHandler.renewMultiProxyVoteCapabilityDiscoveryIfDue(); + } else if (!renewingRecipients.isEmpty()) { + multiProxyHandler.renewMultiProxyVoteCapabilityIfDue(); + } + if (multiProxyHandler.isMultiProxyVoteCapabilityRecoveryBlocked() || !discoveringRecipients.isEmpty()) return false; + Set configuredRecipients = multiProxyHandler.getConfiguredMultiProxyVoteRecipients(); + if (configuredRecipients.isEmpty() && !recipients.isEmpty()) configuredRecipients.addAll(recipients); + Set durableRecipients = new LinkedHashSet<>(recipients); + durableRecipients.addAll(renewingRecipients); + Set legacyRecipients = new LinkedHashSet<>(configuredRecipients); + legacyRecipients.removeAll(durableRecipients); + outbox.requireMultiProxyAcknowledgements(getConfig().getProxyServerName(), durableRecipients); + outbox.setMultiProxyLegacyPendingRecipients(legacyRecipients); + outbox.setMultiProxyCapabilityDiscoveryPending(false); + outbox.setDeliveryStateDirty(true); + return persistTimeVoteDelivery(outbox); + } + + private UUID findLiveVoteId(LiveVoteRetryState retryState) { + for (Map.Entry entry : liveVoteRetries.entrySet()) { + if (entry.getValue() == retryState) return entry.getKey(); + } + return null; + } + + /** Re-sends an unacknowledged outbox using the exact persisted stable ID. */ + private boolean retryDurableMultiProxyOutbox(VoteTimeQueue outbox) { + if (outbox.isMultiProxyCapabilityDiscoveryPending() && !resolveMultiProxyDiscoveryOutbox(outbox)) return false; + if (outbox.hasCompletedMultiProxyAcknowledgements()) { + return finishMultiProxyRetirement(null, outbox); + } + if (!outbox.getMultiProxyLegacyPendingRecipients().isEmpty()) { + Set legacyPending = new LinkedHashSet<>(outbox.getMultiProxyLegacyPendingRecipients()); + outbox.setMultiProxyLegacyPendingRecipients(Collections.emptySet()); + outbox.setDeliveryStateDirty(true); + if (!persistTimeVoteDelivery(outbox)) return false; + if (!multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.vote(outbox.getName(), + outbox.getUuid(), outbox.getService(), outbox.getTime(), false, outbox.isRealVote(), + outbox.getTotals(), outbox.getVoteId(), false, false, 1, 1), legacyPending)) { + outbox.setMultiProxyLegacyPendingRecipients(legacyPending); + outbox.setDeliveryStateDirty(true); + persistTimeVoteDelivery(outbox); + return false; + } + } + if (outbox.getMultiProxyRecipients().isEmpty()) { + boolean handled = markMultiProxyForwardingHandled(null, outbox); + if (handled) scheduleTimeVoteRetry(); + return handled; + } + sendDurableMultiProxyOutbox(outbox); + return false; + } + + private boolean sendDurableMultiProxyOutbox(VoteTimeQueue outbox) { + if (multiProxyHandler == null || outbox.getVoteId() == null || outbox.getMultiProxyOrigin().isBlank()) return false; + Set pending = new LinkedHashSet<>(outbox.getMultiProxyRecipients()); + pending.removeAll(outbox.getMultiProxyAcknowledgedServers()); + if (pending.isEmpty()) return true; + // A durable outbox recipient is known to have required acknowledgements when + // the row was created. It must therefore have a *current* capability lease + // before a retry publishes to it. In particular, a proxy restart/reload clears + // in-memory capability knowledge while the outbox survives; treating that + // recipient as legacy would let a rolled-back peer execute every retry without + // ever acknowledging it. + Set leasedRecipients = new LinkedHashSet<>(); + for (String recipient : multiProxyHandler.getMultiProxyVoteRecipients()) { + if (recipient != null) leasedRecipients.add(recipient.toLowerCase(Locale.ROOT)); + } + Set awaitingRenewal = new LinkedHashSet<>(); + for (String recipient : pending) { + if (recipient != null && !leasedRecipients.contains(recipient.toLowerCase(Locale.ROOT))) { + awaitingRenewal.add(recipient.toLowerCase(Locale.ROOT)); + } + } + if (!awaitingRenewal.isEmpty()) { + // Queue retries may be the only activity after an ACK was lost. Renew the + // handshake here so a recovered peer can become eligible without waiting + // for an unrelated new vote, while the handler bounds advertisements. + multiProxyHandler.renewMultiProxyVoteCapabilityIfDue(); + } + pending.removeIf(recipient -> recipient != null + && awaitingRenewal.contains(recipient.toLowerCase(Locale.ROOT))); + if (pending.isEmpty()) { + // This method is also reached directly from the listener path, where no + // enclosing queue loop schedules the next lease-renewal attempt. Keep the + // durable row live until the peer answers the bounded handshake. + scheduleTimeVoteRetry(); + return false; + } + return multiProxyHandler.sendMultiProxyEnvelopeAccepted(VotingPluginWire.multiProxyVote(outbox.getName(), + outbox.getUuid(), outbox.getService(), outbox.getTime(), false, outbox.isRealVote(), outbox.getTotals(), + outbox.getVoteId(), false, false, 1, 1, outbox.getMultiProxyOrigin()), + pending); + } + + /** Handles an ACK only after confirming it belongs to a configured recipient. */ + private synchronized void handleMultiProxyVoteAcknowledgement(UUID voteId, String recipient) { + if (voteId == null || recipient == null || recipient.isBlank()) return; + LiveVoteRetryState state = liveVoteRetries.get(voteId); + VoteTimeQueue outbox = state == null ? null : state.queuedVote; + if (outbox == null) { + for (VoteTimeQueue candidate : getVoteCacheHandler().getTimeChangeQueue()) { + if (voteId.equals(candidate.getVoteId()) && candidate.isMultiProxyForwardingRequired()) { + outbox = candidate; + break; + } + } + } + if (outbox == null || !outbox.acknowledgeMultiProxyRecipient(recipient)) return; + outbox.setDeliveryStateDirty(true); + if (!persistTimeVoteDelivery(outbox)) { + scheduleTimeVoteRetry(); + return; + } + scheduleTimeVoteRetry(); + } + + private synchronized void handleMultiProxyVoteRetirementAcknowledgement(UUID voteId, String recipient) { + if (voteId == null || recipient == null || recipient.isBlank()) return; + LiveVoteRetryState state = liveVoteRetries.get(voteId); + VoteTimeQueue outbox = state == null ? null : state.queuedVote; + if (outbox == null) { + for (VoteTimeQueue candidate : getVoteCacheHandler().getTimeChangeQueue()) { + if (voteId.equals(candidate.getVoteId()) && candidate.isMultiProxyForwardingRequired()) { + outbox = candidate; + break; + } + } + } + if (outbox == null || !outbox.acknowledgeMultiProxyRetirement(recipient)) return; + outbox.setDeliveryStateDirty(true); + if (!persistTimeVoteDelivery(outbox)) { + scheduleTimeVoteRetry(); + return; + } + scheduleTimeVoteRetry(); + } + + private boolean finishMultiProxyRetirement(LiveVoteRetryState retryState, VoteTimeQueue outbox) { + if (!outbox.hasCompletedMultiProxyRetirements()) { + if (multiProxyHandler != null) { + for (String recipient : outbox.getPendingMultiProxyRetirements()) { + multiProxyHandler.requestMultiProxyVoteRetirement(outbox.getVoteId(), + outbox.getMultiProxyOrigin(), recipient); + } + } + scheduleTimeVoteRetry(); + return false; + } + return markMultiProxyForwardingHandled(retryState, outbox); + } + + /** Persists a queued vote's multi-proxy side-effect fence before later retryable work. */ + private boolean markMultiProxyForwardingHandled(LiveVoteRetryState retryState, VoteTimeQueue queuedVote) { + if (retryState != null) retryState.multiProxyForwardingHandled = true; + if (queuedVote == null) return true; + queuedVote.setMultiProxyForwardingHandled(true); + queuedVote.setMultiProxyCapabilityDiscoveryPending(false); + queuedVote.setDeliveryStateDirty(true); + return persistTimeVoteDelivery(queuedVote); + } + + /** + * Creates the reward cache entry for a backend. Standalone proxy broadcast + * progress belongs to the single voter-keyed canonical state, not to every + * backend row. Marking the local row as already broadcast prevents it from + * emitting a second broadcast while its reward waits for the player. + */ + protected OfflineBungeeVote createCachedRewardVote(UUID voteId, String player, String uuid, String service, long time, + boolean realVote, String text, boolean standaloneProxyBroadcast) { + return new OfflineBungeeVote(voteId, player, uuid, service, time, realVote, text, + standaloneProxyBroadcast, false, Collections.emptySet(), Collections.emptySet(), false, + Collections.emptyMap(), Collections.emptyMap()); + } + + private void markRewardJournalTargets(OfflineBungeeVote owner, Set targets) { + for (String server : targets) { + String key = owner.getVoteId() + ":reward:" + server; + owner.setHttpDeliveryId(REWARD_JOURNAL_TARGET_PREFIX + server, + UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8)).toString()); + } + } + + private boolean isIncompleteRewardJournalOwner(OfflineBungeeVote vote) { + if (vote == null || vote.isRewardDelivered()) return false; + for (String key : vote.getHttpDeliveryIds().keySet()) { + if (key.startsWith(REWARD_JOURNAL_TARGET_PREFIX)) return true; + } + return false; + } + + private String rewardJournalDeliveryId(OfflineBungeeVote owner, String server) { + String encoded = owner.getHttpDeliveryId(REWARD_JOURNAL_TARGET_PREFIX + server); + return encoded; + } + + private boolean isRewardJournalOwner(OfflineBungeeVote vote) { + if (vote == null) return false; + for (String key : vote.getHttpDeliveryIds().keySet()) { + if (key.startsWith(REWARD_JOURNAL_TARGET_PREFIX)) return true; + } + return false; + } + + /** Retries cleanup for an owner whose target rows were already materialized. */ + private boolean retryCompletedRewardJournalOwner(String uuid, OfflineBungeeVote vote) { + if (uuid == null || vote == null || !vote.isRewardDelivered() || !isRewardJournalOwner(vote)) return false; + if (vote.isProxyBroadcastHandled() && !vote.isProxyBroadcastComplete()) return false; + if (!getVoteCacheHandler().tryRemoveOnlineVote(uuid, vote)) { + scheduleCachedVoteDeliveryRetry(); + } + return true; + } + + private boolean materializeRewardJournalOwner(String uuid, OfflineBungeeVote owner) { + for (Map.Entry entry : owner.getHttpDeliveryIds().entrySet()) { + if (!entry.getKey().startsWith(REWARD_JOURNAL_TARGET_PREFIX)) continue; + String normalizedServer = entry.getKey().substring(REWARD_JOURNAL_TARGET_PREFIX.length()); + if (normalizedServer.isEmpty()) continue; + String server = normalizedServer; + for (String configured : getAllConfiguredServers()) { + if (configured.equalsIgnoreCase(normalizedServer)) { + server = configured; + break; + } + } + String deliveryId = entry.getValue(); + OfflineBungeeVote reward = createCachedRewardVote(owner.getVoteId(), owner.getPlayerName(), owner.getUuid(), + owner.getService(), owner.getTime(), owner.isRealVote(), owner.getText(), + owner.isProxyBroadcastHandled()); + reward.setHttpDeliveryId(server, deliveryId); + if (!getVoteCacheHandler().addServerVoteDurably(server, reward)) return false; + } + owner.setRewardDelivered(true); + owner.setDeliveryStateDirty(true); + if (!persistOnlineVoteDelivery(uuid, owner)) return false; + if (!owner.isProxyBroadcastHandled() || owner.isProxyBroadcastComplete()) { + if (!getVoteCacheHandler().tryRemoveOnlineVote(uuid, owner)) { + scheduleCachedVoteDeliveryRetry(); + } + } + return true; + } + + private OfflineBungeeVote createCachedRewardVote(OfflineBungeeVote deliveryState, + boolean standaloneProxyBroadcast) { + return new OfflineBungeeVote(deliveryState.getVoteId(), deliveryState.getPlayerName(), deliveryState.getUuid(), + deliveryState.getService(), deliveryState.getTime(), deliveryState.isRealVote(), deliveryState.getText(), + standaloneProxyBroadcast, false, Collections.emptySet(), Collections.emptySet(), false, + OfflineBungeeVote.decodeHttpDeliveryIds(deliveryState.encodeHttpDeliveryIds()), Collections.emptyMap()); + } + + protected boolean persistUncachedStandaloneBroadcast(String uuid, OfflineBungeeVote state, + boolean alreadyPersisted) { + if (alreadyPersisted || state == null || state.isProxyBroadcastComplete()) return true; + // This canonical row retries only the standalone broadcast. Reward delivery + // remains owned by the target-specific server/online cache entry. + state.setRewardDelivered(true); + state.setBroadcastForwarded(false); + if (getVoteCacheHandler().addOnlineVoteDurably(uuid, state)) return true; + boolean retained = getVoteCacheHandler().retainOnlineVoteForPersistenceRetry(uuid, state); + if (retained) scheduleCachedVoteDeliveryRetry(); + return false; + } + + protected boolean persistAndSendStandaloneBroadcast(String uuid, OfflineBungeeVote state, + Set remainingTargets, Set forwardedServers) { + if (!getVoteCacheHandler().addOnlineVoteDurably(uuid, state)) { + boolean retained = getVoteCacheHandler().retainOnlineVoteForPersistenceRetry(uuid, state); + if (retained) scheduleCachedVoteDeliveryRetry(); + return false; + } + for (String target : remainingTargets) { + Set forwarded = sendProxyBroadcast(Collections.singleton(target), uuid, state.getPlayerName(), + state.getService(), state.getTime(), state.getText(), false, state); + forwardedServers.addAll(forwarded); + if (state.getBroadcastForwardedServers().addAll(forwarded) || state.isDeliveryStateDirty()) { + state.setBroadcastForwarded(state.isProxyBroadcastComplete()); + if (!persistOnlineVoteDelivery(uuid, state)) return false; + } + } + return true; + } private static final class PendingPresenceHandoff { private UUID requestId; private final UUID playerUuid; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java index 7420f0a49..adf9db1ad 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyCommand.java @@ -1,5 +1,7 @@ package com.bencodez.votingplugin.proxy; +import java.util.UUID; + import com.bencodez.advancedcore.api.time.TimeType; public class VotingPluginProxyCommand { @@ -32,10 +34,22 @@ public String execute(String[] args) { if (args.length >= 3) { String user = args[1]; String site = args[2]; - plugin.vote(user, site, false, true, 0, null, null); - return "&aVote sent for " + user + " on " + site; + UUID voteId; + try { + voteId = args.length >= 4 ? UUID.fromString(args[3]) : UUID.randomUUID(); + } catch (IllegalArgumentException invalidId) { + return "&cUsage: vote [retry-id]"; + } + try { + plugin.vote(user, site, false, true, 0, null, null, voteId); + return "&aVote sent for " + user + " on " + site; + } catch (IllegalArgumentException mismatchedRetry) { + return "&cRetry ID does not match that player and site."; + } catch (VotingPluginProxy.VoteRetryException retryable) { + return "&cVote could not be stored safely. Retry with: vote " + user + " " + site + " " + voteId; + } } - return "&cUsage: vote "; + return "&cUsage: vote [retry-id]"; case "forcetimechange": if (args.length >= 2) { @@ -47,6 +61,24 @@ public String execute(String[] args) { case "status": return handleStatusCommand(); + case "httpcode": + if (args.length != 2) return "&cUsage: httpcode "; + try { + return "&aTemporary HTTP backend connection code (expires in 15 minutes and works once):\n&f" + + plugin.createHttpConnectionCode(args[1]); + } catch (IllegalArgumentException | IllegalStateException failure) { + return "&cThe secure HTTP transport is not running."; + } + + case "httprevoke": + if (args.length != 2) return "&cUsage: httprevoke "; + try { + plugin.revokeHttpBackend(args[1]); + return "&aRevoked HTTP backend identity for " + args[1] + ". Generate a new connection code to re-enroll it."; + } catch (IllegalArgumentException | IllegalStateException failure) { + return "&cCould not revoke that HTTP backend identity."; + } + case "multiproxystatus": plugin.getMultiProxyHandler().sendStatus(); return "&aSent status message across multi-proxy"; @@ -92,6 +124,8 @@ private String getHelpMessage() { helpBuilder.append("/votingplugin vote - Send a vote\n"); helpBuilder.append("/votingplugin forcetimechange - Force a time change\n"); helpBuilder.append("/votingplugin status - Check connection status\n"); + helpBuilder.append("/votingpluginproxy httpcode - Create a node-bound one-time HTTP connection code\n"); + helpBuilder.append("/votingpluginproxy httprevoke - Revoke a backend identity before re-enrollment\n"); helpBuilder.append("/votingplugin multiproxystatus - Send status message across proxies\n"); helpBuilder.append("/votingplugin voteparty - Trigger or modify vote party\n"); return helpBuilder.toString(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java index 3c5eeff94..cd00c91e0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginProxyConfig.java @@ -241,6 +241,21 @@ default List getProxyBroadcastOfflineForwardServers() { */ public int getBungeePort(); + /** Bind address for the single-port HTTP transport listener. */ + default String getHttpHost() { + return "0.0.0.0"; + } + + /** Public HTTPS origin embedded in newly generated backend connection codes. */ + default String getHttpPublicEndpoint() { + return ""; + } + + /** Listener port for the single-port HTTP transport. */ + default int getHttpPort() { + return 1297; + } + /** * Gets the plugin message channel. * 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 29becc646..598edac8a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/VotingPluginWire.java @@ -68,6 +68,12 @@ private VotingPluginWire() { // ========================= public static final String SUB_CLEAR_VOTE = "ClearVote"; public static final String SUB_CLEAR_VOTE_PRIMARY = "ClearVotePrimary"; + /** Additive acknowledgement for the reliable multi-proxy vote envelope. */ + public static final String SUB_MULTI_PROXY_VOTE_ACK = "MultiProxyVoteAck"; + public static final String SUB_MULTI_PROXY_VOTE_RETIRE = "MultiProxyVoteRetire"; + public static final String SUB_MULTI_PROXY_VOTE_RETIRE_ACK = "MultiProxyVoteRetireAck"; + /** Additive capability advertisement for durable multi-proxy acknowledgements. */ + public static final String SUB_MULTI_PROXY_CAPABILITIES = "MultiProxyCapabilities"; // ========================= // Field keys (public where referenced externally) @@ -102,6 +108,11 @@ private VotingPluginWire() { public static final String K_BUNGEE_BROADCAST = "bungeeBroadcast"; public static final String K_NUM = "num"; public static final String K_NUMBER_OF_VOTES = "numberOfVotes"; + /** Origin and receiving proxy names for reliable multi-proxy delivery. */ + public static final String K_MULTI_PROXY_ORIGIN = "multiProxyOrigin"; + public static final String K_MULTI_PROXY_RECIPIENT = "multiProxyRecipient"; + public static final String K_MULTI_PROXY_ACK_VERSION = "multiProxyAckVersion"; + public static final String K_MULTI_PROXY_CAPABILITY_REPLY = "multiProxyCapabilityReply"; // VoteUpdate extras public static final String K_PLAYER_UUID = "playerUuid"; @@ -140,6 +151,31 @@ public static JsonEnvelope voteOnline(String player, String uuid, String service .put(K_NUM, num).put(K_NUMBER_OF_VOTES, numberOfVotes).build(); } + /** + * Builds a multi-proxy vote with an additive sender identity for its durable + * acknowledgement. Older receivers safely ignore the extra field. + */ + public static JsonEnvelope multiProxyVote(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(SUB_VOTE).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_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_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num) + .put(K_NUMBER_OF_VOTES, numberOfVotes).put(K_MULTI_PROXY_ORIGIN, safe(origin)).build(); + } + + /** Reliable multi-proxy variant of {@link #voteOnline}. */ + public static JsonEnvelope multiProxyVoteOnline(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(SUB_VOTE_ONLINE).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_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_BUNGEE_BROADCAST, bungeeBroadcast).put(K_NUM, num) + .put(K_NUMBER_OF_VOTES, numberOfVotes).put(K_MULTI_PROXY_ORIGIN, safe(origin)).build(); + } + public static JsonEnvelope voteDelayRejected(String player, String uuid, String service, boolean wasOnline) { return base(SUB_VOTE_DELAY_REJECTED).put(K_PLAYER, safe(player)).put(K_UUID, safe(uuid)) .put(K_SERVICE, safe(service)).put(K_WAS_ONLINE, wasOnline).build(); @@ -366,6 +402,36 @@ public static JsonEnvelope clearVotePrimary(String uuid, String player, String s .put(K_SERVER, safe(server)).build(); } + /** Acknowledges durable completion to the originating proxy. */ + public static JsonEnvelope multiProxyVoteAck(UUID voteId, String origin, String recipient) { + return base(SUB_MULTI_PROXY_VOTE_ACK).put(K_VOTE_ID, voteId == null ? "" : voteId.toString()) + .put(K_MULTI_PROXY_ORIGIN, safe(origin)).put(K_MULTI_PROXY_RECIPIENT, safe(recipient)).build(); + } + + /** Requests deletion of a receiver fence after every vote ACK is durable. */ + public static JsonEnvelope multiProxyVoteRetire(UUID voteId, String origin, String recipient) { + return base(SUB_MULTI_PROXY_VOTE_RETIRE).put(K_VOTE_ID, voteId == null ? "" : voteId.toString()) + .put(K_MULTI_PROXY_ORIGIN, safe(origin)).put(K_MULTI_PROXY_RECIPIENT, safe(recipient)).build(); + } + + /** Confirms idempotent receiver-fence retirement to the originating proxy. */ + public static JsonEnvelope multiProxyVoteRetireAck(UUID voteId, String origin, String recipient) { + return base(SUB_MULTI_PROXY_VOTE_RETIRE_ACK).put(K_VOTE_ID, voteId == null ? "" : voteId.toString()) + .put(K_MULTI_PROXY_ORIGIN, safe(origin)).put(K_MULTI_PROXY_RECIPIENT, safe(recipient)).build(); + } + + /** Advertises support for the additive durable multi-proxy acknowledgement protocol. */ + public static JsonEnvelope multiProxyCapabilities(String recipient, int acknowledgementVersion) { + return multiProxyCapabilities(recipient, acknowledgementVersion, false); + } + + /** Capability response used to complete a bounded bidirectional handshake. */ + public static JsonEnvelope multiProxyCapabilities(String recipient, int acknowledgementVersion, boolean reply) { + return base(SUB_MULTI_PROXY_CAPABILITIES).put(K_MULTI_PROXY_RECIPIENT, safe(recipient)) + .put(K_MULTI_PROXY_ACK_VERSION, acknowledgementVersion) + .put(K_MULTI_PROXY_CAPABILITY_REPLY, reply).build(); + } + // ========================= // Readers (decode) // ========================= diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java index ee1ff540a..4aa394cca 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeConfig.java @@ -209,9 +209,24 @@ public String getBungeeMethod() { } @Override - public int getBungeePort() { - return getData().getInt("BungeeServer.Port", 1297); - } + public int getBungeePort() { + return getData().getInt("BungeeServer.Port", 1297); + } + + @Override + public String getHttpHost() { + return getData().getString("HTTP.Host", "0.0.0.0"); + } + + @Override + public String getHttpPublicEndpoint() { + return getData().getString("HTTP.PublicEndpoint", ""); + } + + @Override + public int getHttpPort() { + return getData().getInt("HTTP.Port", 1297); + } @Override public boolean getDebug() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeJsonVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeJsonVoteCache.java index 0291a0a48..2d369e1d4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeJsonVoteCache.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/BungeeJsonVoteCache.java @@ -1,19 +1,29 @@ package com.bencodez.votingplugin.proxy.bungee; import java.io.File; +import java.nio.charset.StandardCharsets; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; import java.util.Collection; +import java.util.Locale; import com.bencodez.simpleapi.file.BungeeJsonFile; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.proxy.cache.DataNode; import com.bencodez.votingplugin.proxy.cache.GsonDataNode; -import com.bencodez.votingplugin.proxy.cache.IVoteCache; -import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.proxy.cache.IVoteCache; +import com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.util.DurableFiles; +import com.google.gson.GsonBuilder; /** * JSON file-based vote cache for Bungee. */ -public class BungeeJsonVoteCache extends BungeeJsonFile implements IVoteCache { +public class BungeeJsonVoteCache extends BungeeJsonFile implements IVoteCache { private VotingPluginBungee bungee; /** @@ -27,7 +37,7 @@ public BungeeJsonVoteCache(VotingPluginBungee bungee) { initialize(); } - private void initialize() { + private void initialize() { if (!bungee.getDataFolder().exists()) { bungee.getDataFolder().mkdir(); } @@ -43,11 +53,44 @@ public void addTimedVote(int num, VoteTimeQueue voteTimedQueue) { setString(path + ".VoteId", voteTimedQueue.getVoteId() == null ? null : voteTimedQueue.getVoteId().toString()); setString(path + ".UUID", voteTimedQueue.getUuid()); setBoolean(path + ".ProxyBroadcastHandled", voteTimedQueue.isProxyBroadcastHandled()); - setString(path + ".Totals", voteTimedQueue.getTotals()); - setBoolean(path + ".Processed", voteTimedQueue.isProcessed()); - setString(path + ".BroadcastTargets", voteTimedQueue.encodeBroadcastTargets()); - setString(path + ".BroadcastForwardedServers", voteTimedQueue.encodeBroadcastForwardedServers()); - } + setString(path + ".Totals", voteTimedQueue.getTotals()); + setBoolean(path + ".Processed", voteTimedQueue.isProcessed()); + setBoolean(path + ".MultiProxyForwardingHandled", voteTimedQueue.isMultiProxyForwardingHandled()); + setBoolean(path + ".MultiProxyForwardingRequired", voteTimedQueue.isMultiProxyForwardingRequired()); + setBoolean(path + ".MultiProxyCapabilityDiscoveryPending", + voteTimedQueue.isMultiProxyCapabilityDiscoveryPending()); + setBoolean(path + ".RealVote", voteTimedQueue.isRealVote()); + setString(path + ".MultiProxyOrigin", voteTimedQueue.getMultiProxyOrigin()); + setBoolean(path + ".MultiProxyCompletionPending", voteTimedQueue.isMultiProxyCompletionPending()); + setString(path + ".MultiProxyRecipients", voteTimedQueue.encodeMultiProxyRecipients()); + setString(path + ".MultiProxyAcknowledgedServers", voteTimedQueue.encodeMultiProxyAcknowledgedServers()); + setString(path + ".MultiProxyLegacyPendingRecipients", + voteTimedQueue.encodeMultiProxyLegacyPendingRecipients()); + setString(path + ".BroadcastTargets", voteTimedQueue.encodeBroadcastTargets()); + setString(path + ".BroadcastForwardedServers", voteTimedQueue.encodeBroadcastForwardedServers()); + setString(path + ".HttpBroadcastDeliveryIds", voteTimedQueue.encodeHttpBroadcastDeliveryIds()); + } + + @Override + public java.nio.file.Path getStoragePath() { + return getFile().toPath(); + } + + @Override + public synchronized void saveDurably() throws IOException { + Path target = getStoragePath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + if (parent == null) throw new IOException("Vote cache has no parent directory"); + Files.createDirectories(parent); + Path staged = Files.createTempFile(parent, target.getFileName().toString(), ".tmp"); + try { + Files.writeString(staged, new GsonBuilder().setPrettyPrinting().create().toJson(getConf()), + StandardCharsets.UTF_8); + DurableFiles.publishStagedFile(staged, target); + } finally { + Files.deleteIfExists(staged); + } + } public void addVote(String server, int num, OfflineBungeeVote voteData) { String path = "VoteCache." + server + "." + num; @@ -61,8 +104,10 @@ public void addVote(String server, int num, OfflineBungeeVote voteData) { setBoolean(path + ".BroadcastForwarded", voteData.isBroadcastForwarded()); setBoolean(path + ".ProxyBroadcastHandled", voteData.isProxyBroadcastHandled()); setString(path + ".BroadcastTargets", voteData.encodeBroadcastTargets()); - setString(path + ".BroadcastForwardedServers", voteData.encodeBroadcastForwardedServers()); - setBoolean(path + ".RewardDelivered", voteData.isRewardDelivered()); + setString(path + ".BroadcastForwardedServers", voteData.encodeBroadcastForwardedServers()); + setBoolean(path + ".RewardDelivered", voteData.isRewardDelivered()); + setString(path + ".HttpDeliveryIds", voteData.encodeHttpDeliveryIds()); + setString(path + ".HttpBroadcastDeliveryIds", voteData.encodeHttpBroadcastDeliveryIds()); } public void addVoteOnline(String player, int num, OfflineBungeeVote voteData) { @@ -77,8 +122,10 @@ public void addVoteOnline(String player, int num, OfflineBungeeVote voteData) { setBoolean(path + ".BroadcastForwarded", voteData.isBroadcastForwarded()); setBoolean(path + ".ProxyBroadcastHandled", voteData.isProxyBroadcastHandled()); setString(path + ".BroadcastTargets", voteData.encodeBroadcastTargets()); - setString(path + ".BroadcastForwardedServers", voteData.encodeBroadcastForwardedServers()); - setBoolean(path + ".RewardDelivered", voteData.isRewardDelivered()); + setString(path + ".BroadcastForwardedServers", voteData.encodeBroadcastForwardedServers()); + setBoolean(path + ".RewardDelivered", voteData.isRewardDelivered()); + setString(path + ".HttpDeliveryIds", voteData.encodeHttpDeliveryIds()); + setString(path + ".HttpBroadcastDeliveryIds", voteData.encodeHttpBroadcastDeliveryIds()); } public void clearData() { @@ -128,6 +175,35 @@ public void removeTimedVotes() { public int getVotePartyCache(String server) { return getInt("VoteParty.Cache." + server, 0); } + + @Override + public Collection getPendingVotePartyRewardServers() { + Collection encoded = getKeys("VoteParty.PendingRewards"); + Collection servers = new ArrayList<>(); + if (encoded != null) for (String key : encoded) try { + servers.add(decodeServerKey(key)); + } catch (IllegalArgumentException malformedKey) { + // Preserve later valid deliveries when one persisted key is malformed. + } + return servers; + } + + @Override + public Collection getPendingVotePartyRewardIds(String server) { + return getKeys("VoteParty.PendingRewards." + encodeServerKey(server)); + } + + @Override + public PendingVotePartyProxyEffects getPendingVotePartyProxyEffects() { + return new PendingVotePartyProxyEffects(getString("VoteParty.PendingProxyEffects.Broadcast", ""), + getStringList("VoteParty.PendingProxyEffects.Commands", java.util.List.of())); + } + + @Override + public PendingVotePartyProxyEffects getQuarantinedVotePartyProxyEffects() { + return new PendingVotePartyProxyEffects(getString("VoteParty.QuarantinedProxyEffects.Broadcast", ""), + getStringList("VoteParty.QuarantinedProxyEffects.Commands", java.util.List.of())); + } public int getVotePartyCurrentVotes() { return getInt("VoteParty.CurrentVotes", 0); @@ -140,6 +216,47 @@ public int getVotePartyInreaseVotesRequired() { public void setVotePartyCache(String server, int amount) { setInt("VoteParty.Cache." + server, amount); } + + @Override + public void setPendingVotePartyReward(String server, String deliveryId, boolean pending) { + String serverPath = "VoteParty.PendingRewards." + encodeServerKey(server); + String path = serverPath + "." + deliveryId; + if (pending) setBoolean(path, true); + else { + setString(path, null); + Collection remaining = getKeys(serverPath); + if (remaining == null || remaining.isEmpty()) setString(serverPath, null); + } + } + + @Override + public void setPendingVotePartyProxyEffects(PendingVotePartyProxyEffects effects) { + if (effects.isEmpty()) { + remove("VoteParty.PendingProxyEffects"); + return; + } + setString("VoteParty.PendingProxyEffects.Broadcast", effects.broadcast()); + setStringList("VoteParty.PendingProxyEffects.Commands", effects.commands()); + } + + @Override + public void setQuarantinedVotePartyProxyEffects(PendingVotePartyProxyEffects effects) { + if (effects.isEmpty()) { + remove("VoteParty.QuarantinedProxyEffects"); + return; + } + setString("VoteParty.QuarantinedProxyEffects.Broadcast", effects.broadcast()); + setStringList("VoteParty.QuarantinedProxyEffects.Commands", effects.commands()); + } + + private static String encodeServerKey(String server) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(server.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeServerKey(String server) { + return new String(Base64.getUrlDecoder().decode(server), StandardCharsets.UTF_8); + } public void setVotePartyCurrentVotes(int amount) { setInt("VoteParty.CurrentVotes", amount); @@ -176,12 +293,12 @@ public void removeServerVote(String server, String uuid) { return; } // search for vote with uuid and remove it - for (String num : votes) { - GsonDataNode node = getServerVotes(server, num); + for (String num : votes) { + GsonDataNode node = getServerVotes(server, num); if (node == null) { continue; } - DataNode uuidNode = node.get("UUID"); + DataNode uuidNode = node.get("UUID"); if (uuidNode == null) { continue; } @@ -192,18 +309,30 @@ public void removeServerVote(String server, String uuid) { } } - @Override - public void removeVote(String server, OfflineBungeeVote vote) { - Collection votes = getServerVotes(server); + @Override + public void removeVote(String server, OfflineBungeeVote vote) { + if (vote.getServerVoteCacheJsonKey() != null) { + setString("VoteCache." + server + "." + vote.getServerVoteCacheJsonKey(), null); + return; + } + Collection votes = getServerVotes(server); if (votes == null) { return; } for (String num : votes) { GsonDataNode node = getServerVotes(server, num); - if (node == null) { - continue; - } - DataNode uuidNode = node.get("UUID"); + if (node == null) { + continue; + } + DataNode voteIdNode = node.has("VoteId") ? node.get("VoteId") + : node.has("VoteID") ? node.get("VoteID") : null; + if (vote.getVoteId() != null && voteIdNode != null) { + if (vote.getVoteId().toString().equals(voteIdNode.asString())) { + setString("VoteCache." + server + "." + num, null); + } + continue; + } + DataNode uuidNode = node.get("UUID"); DataNode serviceNode = node.get("Service"); DataNode timeNode = node.get("Time"); if (uuidNode == null || serviceNode == null || timeNode == null) { @@ -219,9 +348,13 @@ public void removeVote(String server, OfflineBungeeVote vote) { } } - @Override - public void removeOnlineVote(OfflineBungeeVote vote) { - Collection players = getPlayers(); + @Override + public void removeOnlineVote(OfflineBungeeVote vote) { + if (vote.getOnlineVoteCacheJsonKey() != null && vote.getUuid() != null) { + setString("OnlineCache." + vote.getUuid() + "." + vote.getOnlineVoteCacheJsonKey(), null); + return; + } + Collection players = getPlayers(); if (players == null) { return; } @@ -230,12 +363,20 @@ public void removeOnlineVote(OfflineBungeeVote vote) { if (onlineVotes == null) { continue; } - for (String num : onlineVotes) { - GsonDataNode node = getOnlineVotes(player, num); + for (String num : onlineVotes) { + GsonDataNode node = getOnlineVotes(player, num); if (node == null) { continue; } - DataNode uuidNode = node.get("UUID"); + DataNode voteIdNode = node.has("VoteId") ? node.get("VoteId") + : node.has("VoteID") ? node.get("VoteID") : null; + if (vote.getVoteId() != null && voteIdNode != null) { + if (vote.getVoteId().toString().equals(voteIdNode.asString())) { + setString("OnlineCache." + player + "." + num, null); + } + continue; + } + DataNode uuidNode = node.get("UUID"); DataNode serviceNode = node.get("Service"); DataNode timeNode = node.get("Time"); if (uuidNode == null || serviceNode == null || timeNode == null) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VoteEventBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VoteEventBungee.java index 79c41a464..f01f01cb1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VoteEventBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VoteEventBungee.java @@ -1,6 +1,10 @@ package com.bencodez.votingplugin.proxy.bungee; +import java.util.concurrent.TimeUnit; +import java.util.UUID; + import com.bencodez.votingplugin.util.MinecraftUsernameValidator; +import com.bencodez.votingplugin.proxy.VotingPluginProxy.VoteRetryException; import com.vexsoftware.votifier.bungee.events.VotifierEvent; import com.vexsoftware.votifier.model.Vote; @@ -26,25 +30,44 @@ public VoteEventBungee(VotingPluginBungee plugin) { */ @EventHandler public void onVote(VotifierEvent event) { - plugin.getProxy().getScheduler().runAsync(plugin, new Runnable() { - - @SuppressWarnings("deprecation") - @Override - public void run() { - Vote vote = event.getVote(); - String serviceSite = vote.getServiceName(); - plugin.getLogger().info("Vote received " + MinecraftUsernameValidator.sanitizeForLog(vote.getUsername()) - + " from service site " + MinecraftUsernameValidator.sanitizeForLog(serviceSite)); - - if (serviceSite.isEmpty()) { - serviceSite = "Empty"; - vote.setServiceName(serviceSite); - } + Vote vote = event.getVote(); + String serviceName = vote.getServiceName(); + String serviceSite = serviceName == null || serviceName.isEmpty() ? "Empty" : serviceName; + plugin.getProxy().getScheduler().runAsync(plugin, + new RetryingVote(vote.getUsername(), serviceSite)); - plugin.getVotingPluginProxy().vote(vote.getUsername(), serviceSite, true, true, 0, null, null); - } - }); + } + private final class RetryingVote implements Runnable { + private static final int MAX_ATTEMPTS = 12; + private final String player; + private final String service; + private final UUID voteId = UUID.randomUUID(); + private int attempts; + + private RetryingVote(String player, String service) { + this.player = player; + this.service = service; + } + + @Override + public void run() { + plugin.getLogger().info("Vote received " + MinecraftUsernameValidator.sanitizeForLog(player) + + " from service site " + MinecraftUsernameValidator.sanitizeForLog(service)); + try { + plugin.getVotingPluginProxy().vote(player, service, true, true, 0, null, null, voteId); + } catch (VoteRetryException retryable) { + attempts++; + if (attempts < MAX_ATTEMPTS) { + plugin.getLogger().warning("Vote processing is waiting for durable storage; retrying shortly"); + plugin.getProxy().getScheduler().schedule(plugin, this, 5, TimeUnit.SECONDS); + } else { + plugin.getVotingPluginProxy().abandonLiveVoteRetry(voteId); + plugin.getLogger().severe("Vote processing exhausted bounded durable-storage retries for " + + MinecraftUsernameValidator.sanitizeForLog(player)); + } + } + } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java index bd644c474..111aaf689 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/bungee/VotingPluginBungee.java @@ -7,16 +7,18 @@ import java.io.Reader; import java.net.URL; import java.security.CodeSource; +import java.util.Collection; import java.util.HashSet; import java.util.Locale; import java.util.Map.Entry; import java.util.Queue; import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -312,8 +314,8 @@ public void onPluginMessage(PluginMessageEvent ev) { * @param loadMysql true to fully reinitialize MySQL and rebuild the proxy * runtime, false for a config/runtime-only reload */ - public void reloadPlugin(boolean loadMysql) { - synchronized (reloadLock) { + public void reloadPlugin(boolean loadMysql) { + synchronized (reloadLock) { reloading = true; final String oldChannel = (config != null) ? config.getPluginMessageChannel() : null; @@ -376,11 +378,25 @@ public void reloadPlugin(boolean loadMysql) { return; } - // ========================= - // FULL RELOAD (WITH MYSQL) - // ========================= - - // Save caches best-effort before teardown + // ========================= + // FULL RELOAD (WITH MYSQL) + // ========================= + // Keep the old listener and its original runtime alive when a changed + // config selects a non-HTTP transport while its durable HTTP queue is not + // empty. Recreating first would make the new (possibly blank/changed) + // HTTP endpoint own that queue and can prevent it from draining. + if (votingPluginProxy != null + && votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()) { + votingPluginProxy.reload(); + if (votingPluginProxy.isRetainingHttpTransportForDeferredReconciliation()) { + schedulePlatformTasks(); + reloading = false; + drainQueuedPluginMessagesAfterReloadLock(); + return; + } + } + + // Save caches best-effort before teardown try { if (voteCacheFile != null) { voteCacheFile.save(); @@ -485,8 +501,28 @@ public void reloadPlugin(boolean loadMysql) { try { getVotingPluginProxy().sendServerNameMessage(); } catch (Exception ignored) { - } - } + } + } + + /** The retention branch returns from inside reloadLock; drain only after that lock is released. */ + private void drainQueuedPluginMessagesAfterReloadLock() { + Thread drain = new Thread(() -> { + synchronized (reloadLock) { + // Acquire/release establishes that the returning reload has left its lock. + } + drainQueuedPluginMessages(); + }, "VotingPlugin-Bungee-Reload-Queue-Drain"); + drain.setDaemon(true); + drain.start(); + } + + /** Runs a scheduled deferred replacement only if it is still current while holding reloadLock. */ + private void reloadPluginIfCurrent(boolean loadMysql, BooleanSupplier stillCurrent) { + synchronized (reloadLock) { + if (!stillCurrent.getAsBoolean()) return; + reloadPlugin(loadMysql); + } + } /** * Creates a new VotingPluginProxy instance wired to this platform. @@ -623,6 +659,26 @@ public int getVoteCachePrevWeek() { public int getVoteCacheVotePartyIncreaseVotesRequired() { return voteCacheFile.getVotePartyInreaseVotesRequired(); } + + @Override + public Collection getVoteCachePendingVotePartyServers() { + return voteCacheFile.getPendingVotePartyRewardServers(); + } + + @Override + public Collection getVoteCachePendingVotePartyRewardIds(String server) { + return voteCacheFile.getPendingVotePartyRewardIds(server); + } + + @Override + public com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects getVoteCachePendingVotePartyProxyEffects() { + return voteCacheFile.getPendingVotePartyProxyEffects(); + } + + @Override + public com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects getVoteCacheQuarantinedVotePartyProxyEffects() { + return voteCacheFile.getQuarantinedVotePartyProxyEffects(); + } @Override public boolean isPlayerOnline(String playerName) { @@ -661,15 +717,30 @@ public void runAsync(Runnable run) { runAsyncNow(run); } - @Override - public void runConsoleCommand(String command) { - getProxy().getPluginManager().dispatchCommand(getProxy().getConsole(), command); - } + @Override + public void runConsoleCommand(String command) { + getProxy().getPluginManager().dispatchCommand(getProxy().getConsole(), command); + } + + @Override + protected java.util.concurrent.CompletableFuture runVotePartyConsoleCommand(String command) { + if (!getProxy().getPluginManager().dispatchCommand(getProxy().getConsole(), command)) { + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Bungee declined the vote-party proxy command")); + } + return java.util.concurrent.CompletableFuture.completedFuture(null); + } @Override public void saveVoteCacheFile() { voteCacheFile.save(); } + + @Override + public void saveVotePartyStateDurably() throws java.io.IOException { + com.bencodez.votingplugin.proxy.cache.VotePartyCacheDurability.saveAndVerify( + new File(getDataFolder(), "votecache.json").toPath(), voteCacheFile); + } @Override public boolean sendPluginMessageData(String server, String channel, byte[] data, boolean queue) { @@ -718,18 +789,40 @@ public void setVoteCacheVotePartyCurrentVotes(int votes) { public void setVoteCacheVotePartyIncreaseVotesRequired(int votes) { voteCacheFile.setVotePartyInreaseVotesRequired(votes); } + + @Override + public void setVoteCachePendingVotePartyReward(String server, String deliveryId, boolean pending) { + voteCacheFile.setPendingVotePartyReward(server, deliveryId, pending); + } + + @Override + public void setVoteCachePendingVotePartyProxyEffects( + com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects effects) { + voteCacheFile.setPendingVotePartyProxyEffects(effects); + } + + @Override + public void setVoteCacheQuarantinedVotePartyProxyEffects( + com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects effects) { + voteCacheFile.setQuarantinedVotePartyProxyEffects(effects); + } @Override public void warn(String message) { getLogger().warning(message); } - @Override + @Override public void reloadCore(boolean mysql) { - // mysql==true should do full reloadall behavior on the platform + // mysql==true should do full reloadall behavior on the platform reloadPlugin(mysql); } + @Override + protected void reloadDeferredHttpTransportCore(long generation) { + reloadPluginIfCurrent(true, () -> isDeferredHttpTransportGenerationCurrent(generation)); + } + @Override public void reloadControlConfiguration() throws Exception { config.loadControlConfiguration(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/IVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/IVoteCache.java index 03b6e4977..eeb6ec592 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/IVoteCache.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/IVoteCache.java @@ -1,6 +1,14 @@ package com.bencodez.votingplugin.proxy.cache; import java.util.Collection; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; + +import com.bencodez.votingplugin.util.DurableFiles; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; @@ -10,6 +18,97 @@ */ public interface IVoteCache { + static final String EMERGENCY_JOURNAL_MARKER_FILE_SUFFIX = ".vote-emergency-journal"; + static final String EMERGENCY_JOURNAL_MARKER_VERSION = "1"; + + /** + * Returns the marker file used for emergency-journal recovery coordination. + * + * @return marker file path, or {@code null} when no file-backed cache exists + */ + default Path getEmergencyJournalMarkerPath() { + Path storagePath = getStoragePath(); + if (storagePath == null) { + return null; + } + return storagePath.resolveSibling(storagePath.getFileName() + EMERGENCY_JOURNAL_MARKER_FILE_SUFFIX); + } + + /** + * Returns whether this JSON cache has a marker that indicates emergency journal + * compatibility for MySQL recovery. Legacy files without this marker are not + * treated as emergency recoverables. + * + * @return true when the marker file exists + */ + default boolean hasEmergencyJournalMarker() { + Path markerPath = getEmergencyJournalMarkerPath(); + if (markerPath == null) { + return false; + } + try { + return Files.isRegularFile(markerPath, LinkOption.NOFOLLOW_LINKS) && !Files.isSymbolicLink(markerPath) + && Files.size(markerPath) <= 8 + && EMERGENCY_JOURNAL_MARKER_VERSION.equals(Files.readString(markerPath, StandardCharsets.UTF_8).trim()); + } catch (IOException ignored) { + return false; + } + } + + /** + * Writes the emergency-journal marker file used to indicate that this cache has + * participated in emergency journal recovery flow. + * + * @throws IOException when marker publication fails + */ + default void markEmergencyJournalUsed() throws IOException { + Path markerPath = getEmergencyJournalMarkerPath(); + if (markerPath == null) { + throw new IOException("Vote cache has no emergency journal marker path"); + } + Path normalizedMarker = markerPath.toAbsolutePath().normalize(); + Path parent = normalizedMarker.getParent(); + if (parent != null && Files.isSymbolicLink(parent)) { + throw new IOException("Emergency journal marker parent is a symbolic link"); + } + if (Files.isSymbolicLink(normalizedMarker)) { + throw new IOException("Emergency journal marker is a symbolic link"); + } + if (parent != null) { + Files.createDirectories(parent); + } + Path staged = Files.createTempFile(parent, normalizedMarker.getFileName().toString(), ".journal"); + try { + Files.writeString(staged, EMERGENCY_JOURNAL_MARKER_VERSION, StandardCharsets.UTF_8, StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + DurableFiles.publishStagedFile(staged, normalizedMarker); + } finally { + Files.deleteIfExists(staged); + } + } + + /** + * Returns the file backing this JSON cache, when one exists. + * + * @return backing file path, or {@code null} for non-file implementations + */ + default Path getStoragePath() { + return null; + } + + /** Persists the complete cache and forces it to stable storage. */ + default void saveDurably() throws IOException { + Path path = getStoragePath(); + if (path == null) throw new IOException("Vote cache has no durable storage path"); + save(); + DurableFiles.forceFile(path); + try { + DurableFiles.forceDirectory(path.toAbsolutePath().normalize().getParent()); + } catch (IOException failure) { + throw new DurableFiles.PublishedException(failure); + } + } + /** * Adds a timed vote to the cache. * @@ -117,6 +216,15 @@ public interface IVoteCache { */ int getVotePartyCache(String server); + /** Returns backend IDs with persisted, undelivered vote-party rewards. */ + Collection getPendingVotePartyRewardServers(); + + Collection getPendingVotePartyRewardIds(String server); + + PendingVotePartyProxyEffects getPendingVotePartyProxyEffects(); + + PendingVotePartyProxyEffects getQuarantinedVotePartyProxyEffects(); + /** * Gets the current vote party votes. * @@ -139,6 +247,12 @@ public interface IVoteCache { */ void setVotePartyCache(String server, int amount); + void setPendingVotePartyReward(String server, String deliveryId, boolean pending); + + void setPendingVotePartyProxyEffects(PendingVotePartyProxyEffects effects); + + void setQuarantinedVotePartyProxyEffects(PendingVotePartyProxyEffects effects); + /** * Sets the current vote party votes. * diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/PendingVotePartyProxyEffects.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/PendingVotePartyProxyEffects.java new file mode 100644 index 000000000..2f351f03e --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/PendingVotePartyProxyEffects.java @@ -0,0 +1,36 @@ +package com.bencodez.votingplugin.proxy.cache; + +import java.util.List; + +/** Durable, ordered proxy-side effects for one committed HTTP vote party. */ +public record PendingVotePartyProxyEffects(String broadcast, List commands) { + public static final int MAX_COMMANDS = 1_024; + public static final int MAX_TOTAL_CHARACTERS = 262_144; + + public PendingVotePartyProxyEffects { + broadcast = broadcast == null ? "" : broadcast; + commands = commands == null ? List.of() : List.copyOf(commands); + if (commands.size() > MAX_COMMANDS) { + throw new IllegalArgumentException("Too many pending vote-party proxy commands"); + } + long characters = broadcast.length(); + if (characters > MAX_TOTAL_CHARACTERS) { + throw new IllegalArgumentException("Pending vote-party proxy effects are too large"); + } + for (String command : commands) { + if (command == null) throw new IllegalArgumentException("Null vote-party proxy command"); + characters += command.length(); + if (characters > MAX_TOTAL_CHARACTERS) { + throw new IllegalArgumentException("Pending vote-party proxy effects are too large"); + } + } + } + + public static PendingVotePartyProxyEffects empty() { + return new PendingVotePartyProxyEffects("", List.of()); + } + + public boolean isEmpty() { + return broadcast.isEmpty() && commands.isEmpty(); + } +} 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 2749a1cbb..777483d51 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 @@ -42,7 +42,8 @@ public String buildCreateTableSql(DbType dbType) { + " BOOLEAN NOT NULL DEFAULT FALSE, " + qi("proxyBroadcastHandled") + " BOOLEAN NOT NULL DEFAULT FALSE, " + qi("broadcastTargets") + " TEXT, " + qi("broadcastForwardedServers") + " TEXT, " + qi("rewardDelivered") - + " BOOLEAN NOT NULL DEFAULT FALSE" + ");"; + + " BOOLEAN NOT NULL DEFAULT FALSE, " + qi("httpDeliveryIds") + " TEXT, " + + qi("httpBroadcastDeliveryIds") + " TEXT" + ");"; } return "CREATE TABLE IF NOT EXISTS " + qi(getTableName()) + " (" + qi("id") + " INT AUTO_INCREMENT PRIMARY KEY," @@ -51,7 +52,8 @@ public String buildCreateTableSql(DbType dbType) { + qi("text") + " TEXT," + qi("broadcastForwarded") + " TINYINT(1) NOT NULL DEFAULT 0," + qi("proxyBroadcastHandled") + " TINYINT(1) NOT NULL DEFAULT 0," + qi("broadcastTargets") + " TEXT," + qi("broadcastForwardedServers") + " TEXT," + qi("rewardDelivered") - + " TINYINT(1) NOT NULL DEFAULT 0," + + " TINYINT(1) NOT NULL DEFAULT 0," + qi("httpDeliveryIds") + " TEXT," + + qi("httpBroadcastDeliveryIds") + " TEXT," + "INDEX idx_uuid (" + qi("uuid") + ")," + "INDEX idx_time (" + qi("time") + ")" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; } @@ -160,11 +162,13 @@ public boolean updateProxyBroadcastState(OfflineBungeeVote vote) { return false; } + boolean hasRowId = vote.getOnlineVoteCacheRowId() > 0; boolean hasVoteId = vote.getVoteId() != null; String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("broadcastForwarded") + " = ?, " + qi("proxyBroadcastHandled") + " = ?, " + qi("broadcastTargets") + " = ?, " - + qi("broadcastForwardedServers") + " = ?, " + qi("rewardDelivered") + " = ? WHERE " - + (hasVoteId ? qi("voteid") + " = ?;" + + qi("broadcastForwardedServers") + " = ?, " + qi("rewardDelivered") + " = ?, " + + qi("httpDeliveryIds") + " = ?, " + qi("httpBroadcastDeliveryIds") + " = ? WHERE " + + (hasRowId ? qi("id") + " = ?;" : hasVoteId ? qi("voteid") + " = ?;" : qi("uuid") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ?;"); try (Connection conn = mysql.getConnectionManager().getConnection(); @@ -180,16 +184,20 @@ public boolean updateProxyBroadcastState(OfflineBungeeVote vote) { } ps.setString(3, vote.encodeBroadcastTargets()); ps.setString(4, vote.encodeBroadcastForwardedServers()); - if (hasVoteId) { - ps.setString(6, vote.getVoteId().toString()); + ps.setString(6, vote.encodeHttpDeliveryIds()); + ps.setString(7, vote.encodeHttpBroadcastDeliveryIds()); + if (hasRowId) { + ps.setInt(8, vote.getOnlineVoteCacheRowId()); + } else if (hasVoteId) { + ps.setString(8, vote.getVoteId().toString()); } else { if (getDbType() == DbType.POSTGRESQL) { - ps.setObject(6, UUID.fromString(vote.getUuid())); + ps.setObject(8, UUID.fromString(vote.getUuid())); } else { - ps.setString(6, vote.getUuid()); + ps.setString(8, vote.getUuid()); } - ps.setString(7, vote.getService()); - ps.setLong(8, vote.getTime()); + ps.setString(9, vote.getService()); + ps.setLong(10, vote.getTime()); } return ps.executeUpdate() > 0; } catch (SQLException | IllegalArgumentException e) { @@ -269,6 +277,8 @@ private void ensureProxyBroadcastStateColumns() { ensureColumn("broadcastTargets", "TEXT"); ensureColumn("broadcastForwardedServers", "TEXT"); ensureColumn("rewardDelivered", booleanType); + ensureColumn("httpDeliveryIds", "TEXT"); + ensureColumn("httpBroadcastDeliveryIds", "TEXT"); } private void ensureColumn(String column, String type) { @@ -317,16 +327,44 @@ private void ensureColumn(String column, String type) { */ public void insertVote(UUID voteId, String uuid, String playerName, String service, long time, boolean real, String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, String broadcastTargets, - String broadcastForwardedServers, boolean rewardDelivered) { + String broadcastForwardedServers, boolean rewardDelivered, String httpDeliveryIds, + String httpBroadcastDeliveryIds) { + tryInsertVote(voteId, uuid, playerName, service, time, real, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered, + httpDeliveryIds, httpBroadcastDeliveryIds); + } + + /** Inserts a vote and reports whether the database accepted it. */ + public boolean tryInsertVote(UUID voteId, String uuid, String playerName, String service, long time, boolean real, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, String broadcastTargets, + String broadcastForwardedServers, boolean rewardDelivered, String httpDeliveryIds, + String httpBroadcastDeliveryIds) { + return tryInsertVoteAndGetId(voteId, uuid, playerName, service, time, real, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered, httpDeliveryIds, + httpBroadcastDeliveryIds) > 0; + } + + /** + * Inserts a vote and returns its durable primary key. Legacy rows can share + * their visible vote fields, so this identity is required for exact updates. + * + * @return the generated primary key, or {@code -1} when insertion or key + * retrieval failed + */ + public int tryInsertVoteAndGetId(UUID voteId, String uuid, String playerName, String service, long time, + boolean real, String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, + String broadcastTargets, String broadcastForwardedServers, boolean rewardDelivered, + String httpDeliveryIds, String httpBroadcastDeliveryIds) { String sql = "INSERT INTO " + qi(getTableName()) + " (" + qi("uuid") + ", " + qi("voteid") + ", " + qi("playerName") + ", " + qi("service") + ", " + qi("time") + ", " + qi("realVote") + ", " + qi("text") + ", " + qi("broadcastForwarded") + ", " + qi("proxyBroadcastHandled") + ", " + qi("broadcastTargets") + ", " + qi("broadcastForwardedServers") + ", " + qi("rewardDelivered") - + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + + ", " + qi("httpDeliveryIds") + ", " + qi("httpBroadcastDeliveryIds") + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; try (Connection conn = mysql.getConnectionManager().getConnection(); - PreparedStatement ps = conn.prepareStatement(sql)) { + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { if (getDbType() == DbType.POSTGRESQL) { ps.setObject(1, UUID.fromString(uuid)); @@ -357,10 +395,16 @@ public void insertVote(UUID voteId, String uuid, String playerName, String servi } ps.setString(10, broadcastTargets); ps.setString(11, broadcastForwardedServers); + ps.setString(13, httpDeliveryIds); + ps.setString(14, httpBroadcastDeliveryIds); - ps.executeUpdate(); + if (ps.executeUpdate() != 1) return -1; + try (ResultSet keys = ps.getGeneratedKeys()) { + return keys.next() ? keys.getInt(1) : -1; + } } catch (SQLException | IllegalArgumentException e) { debug(e); + return -1; } } @@ -407,24 +451,37 @@ public void removeVoteById(int id) { * @param vote the vote to remove */ public void removeVote(OfflineBungeeVote vote) { - String sql = "DELETE FROM " + qi(getTableName()) + " WHERE " + qi("uuid") + " = ? AND " + qi("service") - + " = ? AND " + qi("time") + " = ?;"; + tryRemoveVote(vote); + } + + /** Removes one stable vote identity and reports whether the statement completed. */ + public boolean tryRemoveVote(OfflineBungeeVote vote) { + boolean hasRowId = vote.getOnlineVoteCacheRowId() > 0; + boolean byVoteId = vote.getVoteId() != null; + String sql = "DELETE FROM " + qi(getTableName()) + " WHERE " + + (hasRowId ? qi("id") + " = ?" : byVoteId ? qi("voteid") + " = ?" : qi("uuid") + " = ? AND " + qi("service") + + " = ? AND " + qi("time") + " = ?") + ";"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { - - if (getDbType() == DbType.POSTGRESQL) { - ps.setObject(1, UUID.fromString(vote.getUuid())); + if (hasRowId) { + ps.setInt(1, vote.getOnlineVoteCacheRowId()); + } else if (byVoteId) { + ps.setString(1, vote.getVoteId().toString()); } else { - ps.setString(1, vote.getUuid()); + if (getDbType() == DbType.POSTGRESQL) { + ps.setObject(1, UUID.fromString(vote.getUuid())); + } else { + ps.setString(1, vote.getUuid()); + } + ps.setString(2, vote.getService()); + ps.setLong(3, vote.getTime()); } - - ps.setString(2, vote.getService()); - ps.setLong(3, vote.getTime()); - ps.executeUpdate(); + return true; } catch (SQLException | IllegalArgumentException e) { debug(e); + return false; } } @@ -463,7 +520,8 @@ private List selectVotes(String sql, Object[] params) { (getDbType() == DbType.POSTGRESQL ? rs.getBoolean("realVote") : rs.getInt("realVote") == 1), rs.getString("text"), rs.getBoolean("broadcastForwarded"), rs.getBoolean("proxyBroadcastHandled"), rs.getString("broadcastTargets"), - rs.getString("broadcastForwardedServers"), rs.getBoolean("rewardDelivered"))); + rs.getString("broadcastForwardedServers"), rs.getBoolean("rewardDelivered"), + rs.getString("httpDeliveryIds"), rs.getString("httpBroadcastDeliveryIds"))); } } } catch (SQLException e) { @@ -489,6 +547,8 @@ public static class VoteRow { private final String broadcastTargets; private final String broadcastForwardedServers; private final boolean rewardDelivered; + private final String httpDeliveryIds; + private final String httpBroadcastDeliveryIds; /** * Constructor for VoteRow. @@ -508,7 +568,8 @@ public static class VoteRow { */ public VoteRow(int id, String voteId, String uuid, String playerName, String service, long time, boolean realVote, String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, - String broadcastTargets, String broadcastForwardedServers, boolean rewardDelivered) { + String broadcastTargets, String broadcastForwardedServers, boolean rewardDelivered, String httpDeliveryIds, + String httpBroadcastDeliveryIds) { this.id = id; this.voteId = voteId; this.uuid = uuid; @@ -522,6 +583,8 @@ public VoteRow(int id, String voteId, String uuid, String playerName, String ser this.broadcastTargets = broadcastTargets; this.broadcastForwardedServers = broadcastForwardedServers; this.rewardDelivered = rewardDelivered; + this.httpDeliveryIds = httpDeliveryIds; + this.httpBroadcastDeliveryIds = httpBroadcastDeliveryIds; } /** @@ -611,5 +674,13 @@ public String getBroadcastForwardedServers() { public boolean isRewardDelivered() { return rewardDelivered; } + + public String getHttpDeliveryIds() { + return httpDeliveryIds; + } + + public String getHttpBroadcastDeliveryIds() { + return httpBroadcastDeliveryIds; + } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyTimedVoteCacheTable.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyTimedVoteCacheTable.java index b311494e0..1782cf756 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyTimedVoteCacheTable.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/ProxyTimedVoteCacheTable.java @@ -39,8 +39,18 @@ public String buildCreateTableSql(DbType dbType) { + qi("proxyBroadcastHandled") + " BOOLEAN NOT NULL DEFAULT FALSE, " + qi("totals") + " TEXT, " + qi("processed") + " BOOLEAN NOT NULL DEFAULT FALSE, " + + qi("multiProxyForwardingHandled") + " BOOLEAN NOT NULL DEFAULT FALSE, " + + qi("multiProxyForwardingRequired") + " BOOLEAN NOT NULL DEFAULT FALSE, " + + qi("multiProxyCapabilityDiscoveryPending") + " BOOLEAN NOT NULL DEFAULT FALSE, " + + qi("realVote") + " BOOLEAN NOT NULL DEFAULT TRUE, " + + qi("multiProxyOrigin") + " VARCHAR(100), " + + qi("multiProxyCompletionPending") + " BOOLEAN NOT NULL DEFAULT FALSE, " + + qi("multiProxyRecipients") + " TEXT, " + + qi("multiProxyAcknowledgedServers") + " TEXT, " + qi("broadcastTargets") + " TEXT, " - + qi("broadcastForwardedServers") + " TEXT" + + qi("broadcastForwardedServers") + " TEXT, " + + qi("httpBroadcastDeliveryIds") + " TEXT, " + + qi("multiProxyLegacyPendingRecipients") + " TEXT" + ");"; } @@ -54,8 +64,18 @@ public String buildCreateTableSql(DbType dbType) { + qi("proxyBroadcastHandled") + " TINYINT(1) NOT NULL DEFAULT 0," + qi("totals") + " TEXT," + qi("processed") + " TINYINT(1) NOT NULL DEFAULT 0," + + qi("multiProxyForwardingHandled") + " TINYINT(1) NOT NULL DEFAULT 0," + + qi("multiProxyForwardingRequired") + " TINYINT(1) NOT NULL DEFAULT 0," + + qi("multiProxyCapabilityDiscoveryPending") + " TINYINT(1) NOT NULL DEFAULT 0," + + qi("realVote") + " TINYINT(1) NOT NULL DEFAULT 1," + + qi("multiProxyOrigin") + " VARCHAR(100)," + + qi("multiProxyCompletionPending") + " TINYINT(1) NOT NULL DEFAULT 0," + + qi("multiProxyRecipients") + " TEXT," + + qi("multiProxyAcknowledgedServers") + " TEXT," + qi("broadcastTargets") + " TEXT," + qi("broadcastForwardedServers") + " TEXT," + + qi("httpBroadcastDeliveryIds") + " TEXT," + + qi("multiProxyLegacyPendingRecipients") + " TEXT," + "INDEX idx_time (" + qi("time") + ")" + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"; } @@ -111,6 +131,26 @@ private void ensureProxyBroadcastColumns() { ensureColumn("processed", getDbType() == DbType.POSTGRESQL ? "BOOLEAN NOT NULL DEFAULT FALSE" : "TINYINT(1) NOT NULL DEFAULT 0"); + ensureColumn("multiProxyForwardingHandled", getDbType() == DbType.POSTGRESQL + ? "BOOLEAN NOT NULL DEFAULT FALSE" + : "TINYINT(1) NOT NULL DEFAULT 0"); + ensureColumn("multiProxyForwardingRequired", getDbType() == DbType.POSTGRESQL + ? "BOOLEAN NOT NULL DEFAULT FALSE" + : "TINYINT(1) NOT NULL DEFAULT 0"); + ensureColumn("multiProxyCapabilityDiscoveryPending", getDbType() == DbType.POSTGRESQL + ? "BOOLEAN NOT NULL DEFAULT FALSE" + : "TINYINT(1) NOT NULL DEFAULT 0"); + ensureColumn("realVote", getDbType() == DbType.POSTGRESQL + ? "BOOLEAN NOT NULL DEFAULT TRUE" + : "TINYINT(1) NOT NULL DEFAULT 1"); + ensureColumn("multiProxyOrigin", "VARCHAR(100)"); + ensureColumn("multiProxyCompletionPending", getDbType() == DbType.POSTGRESQL + ? "BOOLEAN NOT NULL DEFAULT FALSE" + : "TINYINT(1) NOT NULL DEFAULT 0"); + ensureColumn("multiProxyRecipients", "TEXT"); + ensureColumn("multiProxyAcknowledgedServers", "TEXT"); + ensureColumn("httpBroadcastDeliveryIds", "TEXT"); + ensureColumn("multiProxyLegacyPendingRecipients", "TEXT"); } private void ensureColumn(String column, String type) { @@ -156,13 +196,23 @@ private void ensureIndexes() { * @param processed whether normal replay processing completed * @return true when the row was inserted */ - public boolean insertTimedVote(UUID voteId, String uuid, String playerName, String service, long time, + private boolean insertTimedVote(UUID voteId, String uuid, String playerName, String service, long time, boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, - boolean processed) { + boolean processed, boolean multiProxyForwardingHandled, boolean multiProxyForwardingRequired, + boolean multiProxyCapabilityDiscoveryPending, + boolean realVote, String multiProxyOrigin, boolean multiProxyCompletionPending, String multiProxyRecipients, + String multiProxyAcknowledgedServers, String httpBroadcastDeliveryIds, + String multiProxyLegacyPendingRecipients) { String sql = "INSERT INTO " + qi(getTableName()) + " (" + qi("playerName") + ", " + qi("service") + ", " + qi("time") + ", " + qi("voteId") + ", " + qi("uuid") + ", " + qi("proxyBroadcastHandled") + ", " + qi("broadcastTargets") + ", " + qi("broadcastForwardedServers") + ", " + qi("totals") + ", " - + qi("processed") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + + qi("processed") + ", " + qi("multiProxyForwardingHandled") + ", " + + qi("multiProxyForwardingRequired") + ", " + qi("realVote") + ", " + qi("multiProxyOrigin") + + ", " + qi("multiProxyCapabilityDiscoveryPending") + + ", " + qi("multiProxyCompletionPending") + ", " + qi("multiProxyRecipients") + ", " + + qi("multiProxyAcknowledgedServers") + ", " + qi("httpBroadcastDeliveryIds") + ", " + + qi("multiProxyLegacyPendingRecipients") + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, playerName); @@ -180,9 +230,27 @@ public boolean insertTimedVote(UUID voteId, String uuid, String playerName, Stri ps.setString(9, totals); if (getDbType() == DbType.POSTGRESQL) { ps.setBoolean(10, processed); + ps.setBoolean(11, multiProxyForwardingHandled); } else { ps.setInt(10, processed ? 1 : 0); + ps.setInt(11, multiProxyForwardingHandled ? 1 : 0); } + if (getDbType() == DbType.POSTGRESQL) { + ps.setBoolean(12, multiProxyForwardingRequired); + ps.setBoolean(13, realVote); + } else { + ps.setInt(12, multiProxyForwardingRequired ? 1 : 0); + ps.setInt(13, realVote ? 1 : 0); + } + ps.setString(14, multiProxyOrigin); + if (getDbType() == DbType.POSTGRESQL) ps.setBoolean(15, multiProxyCapabilityDiscoveryPending); + else ps.setInt(15, multiProxyCapabilityDiscoveryPending ? 1 : 0); + if (getDbType() == DbType.POSTGRESQL) ps.setBoolean(16, multiProxyCompletionPending); + else ps.setInt(16, multiProxyCompletionPending ? 1 : 0); + ps.setString(17, multiProxyRecipients); + ps.setString(18, multiProxyAcknowledgedServers); + ps.setString(19, httpBroadcastDeliveryIds); + ps.setString(20, multiProxyLegacyPendingRecipients); ps.executeUpdate(); return true; } catch (SQLException e) { @@ -191,6 +259,44 @@ public boolean insertTimedVote(UUID voteId, String uuid, String playerName, Stri } } + /** Inserts the complete durable replay state without a post-insert crash window. */ + public boolean insertTimedVote(VoteTimeQueue vote) { + if (vote == null) return false; + return insertTimedVote(vote.getVoteId(), vote.getUuid(), vote.getName(), vote.getService(), vote.getTime(), + vote.isProxyBroadcastHandled(), vote.encodeBroadcastTargets(), vote.encodeBroadcastForwardedServers(), + vote.getTotals(), vote.isProcessed(), vote.isMultiProxyForwardingHandled(), + vote.isMultiProxyForwardingRequired(), vote.isMultiProxyCapabilityDiscoveryPending(), vote.isRealVote(), + vote.getMultiProxyOrigin(), + vote.isMultiProxyCompletionPending(), + vote.encodeMultiProxyRecipients(), vote.encodeMultiProxyAcknowledgedServers(), + vote.encodeHttpBroadcastDeliveryIds(), vote.encodeMultiProxyLegacyPendingRecipients()); + } + + /** Backward-compatible insert overload. */ + public boolean insertTimedVote(UUID voteId, String uuid, String playerName, String service, long time, + boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, + boolean processed, boolean multiProxyForwardingHandled, String httpBroadcastDeliveryIds) { + return insertTimedVote(voteId, uuid, playerName, service, time, proxyBroadcastHandled, broadcastTargets, + broadcastForwardedServers, totals, processed, multiProxyForwardingHandled, false, false, true, "", false, "", "", + httpBroadcastDeliveryIds, ""); + } + + /** Backward-compatible insert overload without HTTP delivery state. */ + public boolean insertTimedVote(UUID voteId, String uuid, String playerName, String service, long time, + boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, + boolean processed) { + return insertTimedVote(voteId, uuid, playerName, service, time, proxyBroadcastHandled, broadcastTargets, + broadcastForwardedServers, totals, processed, false, ""); + } + + /** Backward-compatible insert overload without the durable multi-proxy forwarding fence. */ + public boolean insertTimedVote(UUID voteId, String uuid, String playerName, String service, long time, + boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, + boolean processed, String httpBroadcastDeliveryIds) { + return insertTimedVote(voteId, uuid, playerName, service, time, proxyBroadcastHandled, broadcastTargets, + broadcastForwardedServers, totals, processed, false, httpBroadcastDeliveryIds); + } + /** * Updates a queued vote's standalone broadcast delivery state. * @@ -198,11 +304,18 @@ public boolean insertTimedVote(UUID voteId, String uuid, String playerName, Stri * @return true when the durable state update completed */ public boolean updateTimedVote(VoteTimeQueue vote) { + boolean hasRowId = vote.getTimedVoteCacheRowId() > 0; boolean hasVoteId = vote.getVoteId() != null; String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("proxyBroadcastHandled") + " = ?, " + qi("broadcastTargets") + " = ?, " + qi("broadcastForwardedServers") + " = ?, " + qi("totals") - + " = ?, " + qi("processed") + " = ?, " + qi("uuid") + " = ? WHERE " - + (hasVoteId ? qi("voteId") + " = ?;" + + " = ?, " + qi("processed") + " = ?, " + qi("multiProxyForwardingHandled") + " = ?, " + + qi("multiProxyForwardingRequired") + " = ?, " + qi("realVote") + " = ?, " + + qi("multiProxyOrigin") + " = ?, " + qi("multiProxyCapabilityDiscoveryPending") + " = ?, " + + qi("multiProxyCompletionPending") + " = ?, " + + qi("multiProxyRecipients") + " = ?, " + + qi("multiProxyAcknowledgedServers") + " = ?, " + qi("uuid") + " = ?, " + + qi("httpBroadcastDeliveryIds") + " = ?, " + qi("multiProxyLegacyPendingRecipients") + " = ? WHERE " + + (hasRowId ? qi("id") + " = ?;" : hasVoteId ? qi("voteId") + " = ?;" : qi("playerName") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ?;"); try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { @@ -216,16 +329,36 @@ public boolean updateTimedVote(VoteTimeQueue vote) { ps.setString(4, vote.getTotals()); if (getDbType() == DbType.POSTGRESQL) { ps.setBoolean(5, vote.isProcessed()); + ps.setBoolean(6, vote.isMultiProxyForwardingHandled()); } else { ps.setInt(5, vote.isProcessed() ? 1 : 0); + ps.setInt(6, vote.isMultiProxyForwardingHandled() ? 1 : 0); } - ps.setString(6, vote.getUuid()); - if (hasVoteId) { - ps.setString(7, vote.getVoteId().toString()); + if (getDbType() == DbType.POSTGRESQL) { + ps.setBoolean(7, vote.isMultiProxyForwardingRequired()); + ps.setBoolean(8, vote.isRealVote()); + } else { + ps.setInt(7, vote.isMultiProxyForwardingRequired() ? 1 : 0); + ps.setInt(8, vote.isRealVote() ? 1 : 0); + } + ps.setString(9, vote.getMultiProxyOrigin()); + if (getDbType() == DbType.POSTGRESQL) ps.setBoolean(10, vote.isMultiProxyCapabilityDiscoveryPending()); + else ps.setInt(10, vote.isMultiProxyCapabilityDiscoveryPending() ? 1 : 0); + if (getDbType() == DbType.POSTGRESQL) ps.setBoolean(11, vote.isMultiProxyCompletionPending()); + else ps.setInt(11, vote.isMultiProxyCompletionPending() ? 1 : 0); + ps.setString(12, vote.encodeMultiProxyRecipients()); + ps.setString(13, vote.encodeMultiProxyAcknowledgedServers()); + ps.setString(14, vote.getUuid()); + ps.setString(15, vote.encodeHttpBroadcastDeliveryIds()); + ps.setString(16, vote.encodeMultiProxyLegacyPendingRecipients()); + if (hasRowId) { + ps.setInt(17, vote.getTimedVoteCacheRowId()); + } else if (hasVoteId) { + ps.setString(17, vote.getVoteId().toString()); } else { - ps.setString(7, vote.getName()); - ps.setString(8, vote.getService()); - ps.setLong(9, vote.getTime()); + ps.setString(17, vote.getName()); + ps.setString(18, vote.getService()); + ps.setLong(19, vote.getTime()); } ps.executeUpdate(); return true; @@ -235,6 +368,33 @@ public boolean updateTimedVote(VoteTimeQueue vote) { } } + /** Assigns a stable ID to a pre-ID timed row before reliable forwarding. */ + public boolean assignLegacyTimedVoteId(VoteTimeQueue vote, UUID voteId) { + if (vote == null || voteId == null || vote.getTimedVoteCacheRowId() <= 0) return false; + String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("voteId") + " = ? WHERE " + + qi("id") + " = ? AND (" + + qi("voteId") + " IS NULL OR " + qi("voteId") + " = '' OR " + qi("voteId") + " = ?);"; + try (Connection conn = mysql.getConnectionManager().getConnection(); + PreparedStatement ps = conn.prepareStatement(sql)) { + ps.setString(1, voteId.toString()); + ps.setInt(2, vote.getTimedVoteCacheRowId()); + ps.setString(3, voteId.toString()); + if (ps.executeUpdate() > 0) return true; + String verifySql = "SELECT " + qi("id") + " FROM " + qi(getTableName()) + " WHERE " + + qi("id") + " = ? AND " + qi("voteId") + " = ?;"; + try (PreparedStatement verify = conn.prepareStatement(verifySql)) { + verify.setInt(1, vote.getTimedVoteCacheRowId()); + verify.setString(2, voteId.toString()); + try (ResultSet rows = verify.executeQuery()) { + return rows.next(); + } + } + } catch (SQLException failure) { + debug(failure); + return false; + } + } + // --- GET --- /** * Gets all timed votes. @@ -277,13 +437,16 @@ public void removeVoteById(int id) { * @return true when the durable delete completed */ public boolean removeVote(VoteTimeQueue vote) { + boolean hasRowId = vote.getTimedVoteCacheRowId() > 0; boolean hasVoteId = vote.getVoteId() != null; String sql = "DELETE FROM " + qi(getTableName()) + " WHERE " - + (hasVoteId ? qi("voteId") + " = ?;" + + (hasRowId ? qi("id") + " = ?;" : hasVoteId ? qi("voteId") + " = ?;" : qi("playerName") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ?;"); try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { - if (hasVoteId) { + if (hasRowId) { + ps.setInt(1, vote.getTimedVoteCacheRowId()); + } else if (hasVoteId) { ps.setString(1, vote.getVoteId().toString()); } else { ps.setString(1, vote.getName()); @@ -352,7 +515,17 @@ private List selectVotes(String sql, Object[] params) { rs.getString("broadcastTargets"), rs.getString("broadcastForwardedServers"), rs.getString("totals"), - rs.getBoolean("processed") + rs.getBoolean("processed"), + rs.getBoolean("multiProxyForwardingHandled"), + rs.getBoolean("multiProxyForwardingRequired"), + rs.getBoolean("multiProxyCapabilityDiscoveryPending"), + rs.getBoolean("realVote"), + rs.getString("multiProxyOrigin"), + rs.getBoolean("multiProxyCompletionPending"), + rs.getString("multiProxyRecipients"), + rs.getString("multiProxyAcknowledgedServers"), + rs.getString("httpBroadcastDeliveryIds"), + rs.getString("multiProxyLegacyPendingRecipients") )); } } @@ -388,6 +561,16 @@ public static class TimedVoteRow { private final String broadcastForwardedServers; private final String totals; private final boolean processed; + private final boolean multiProxyForwardingHandled; + private final boolean multiProxyForwardingRequired; + private final boolean multiProxyCapabilityDiscoveryPending; + private final boolean realVote; + private final String multiProxyOrigin; + private final boolean multiProxyCompletionPending; + private final String multiProxyRecipients; + private final String multiProxyAcknowledgedServers; + private final String httpBroadcastDeliveryIds; + private final String multiProxyLegacyPendingRecipients; /** * Constructor for TimedVoteRow. @@ -404,7 +587,11 @@ public static class TimedVoteRow { */ public TimedVoteRow(int id, String playerName, String service, long time, UUID voteId, String uuid, boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, - boolean processed) { + boolean processed, boolean multiProxyForwardingHandled, boolean multiProxyForwardingRequired, + boolean multiProxyCapabilityDiscoveryPending, boolean realVote, String multiProxyOrigin, + boolean multiProxyCompletionPending, String multiProxyRecipients, + String multiProxyAcknowledgedServers, String httpBroadcastDeliveryIds, + String multiProxyLegacyPendingRecipients) { this.id = id; this.playerName = playerName; this.service = service; @@ -416,6 +603,33 @@ public TimedVoteRow(int id, String playerName, String service, long time, UUID v this.broadcastForwardedServers = broadcastForwardedServers; this.totals = totals; this.processed = processed; + this.multiProxyForwardingHandled = multiProxyForwardingHandled; + this.multiProxyForwardingRequired = multiProxyForwardingRequired; + this.multiProxyCapabilityDiscoveryPending = multiProxyCapabilityDiscoveryPending; + this.realVote = realVote; + this.multiProxyOrigin = multiProxyOrigin; + this.multiProxyCompletionPending = multiProxyCompletionPending; + this.multiProxyRecipients = multiProxyRecipients; + this.multiProxyAcknowledgedServers = multiProxyAcknowledgedServers; + this.httpBroadcastDeliveryIds = httpBroadcastDeliveryIds; + this.multiProxyLegacyPendingRecipients = multiProxyLegacyPendingRecipients; + } + + /** Backward-compatible row constructor without HTTP delivery state. */ + public TimedVoteRow(int id, String playerName, String service, long time, UUID voteId, String uuid, + boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, + boolean processed) { + this(id, playerName, service, time, voteId, uuid, proxyBroadcastHandled, broadcastTargets, + broadcastForwardedServers, totals, processed, false, false, false, true, "", false, "", "", null, null); + } + + /** Backward-compatible row constructor without the durable multi-proxy forwarding fence. */ + public TimedVoteRow(int id, String playerName, String service, long time, UUID voteId, String uuid, + boolean proxyBroadcastHandled, String broadcastTargets, String broadcastForwardedServers, String totals, + boolean processed, String httpBroadcastDeliveryIds) { + this(id, playerName, service, time, voteId, uuid, proxyBroadcastHandled, broadcastTargets, + broadcastForwardedServers, totals, processed, false, false, false, true, "", false, "", "", + httpBroadcastDeliveryIds, null); } /** @@ -490,5 +704,23 @@ public String getTotals() { public boolean isProcessed() { return processed; } + + public boolean isMultiProxyForwardingHandled() { + return multiProxyForwardingHandled; + } + + public boolean isMultiProxyForwardingRequired() { return multiProxyForwardingRequired; } + + public boolean isMultiProxyCapabilityDiscoveryPending() { return multiProxyCapabilityDiscoveryPending; } + public boolean isRealVote() { return realVote; } + public String getMultiProxyOrigin() { return multiProxyOrigin; } + public boolean isMultiProxyCompletionPending() { return multiProxyCompletionPending; } + public String getMultiProxyRecipients() { return multiProxyRecipients; } + public String getMultiProxyLegacyPendingRecipients() { return multiProxyLegacyPendingRecipients; } + public String getMultiProxyAcknowledgedServers() { return multiProxyAcknowledgedServers; } + + public String getHttpBroadcastDeliveryIds() { + return httpBroadcastDeliveryIds; + } } } 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 d9e48e993..66d2c3e17 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 @@ -58,6 +58,8 @@ public String buildCreateTableSql(DbType dbType) { .append(qi("proxyBroadcastHandled")).append(" BOOLEAN NOT NULL DEFAULT FALSE, ") .append(qi("broadcastTargets")).append(" TEXT, ").append(qi("broadcastForwardedServers")) .append(" TEXT, ").append(qi("rewardDelivered")).append(" BOOLEAN NOT NULL DEFAULT FALSE, ") + .append(qi("httpDeliveryIds")).append(" TEXT, ") + .append(qi("httpBroadcastDeliveryIds")).append(" TEXT, ") .append(qi("server")).append(" VARCHAR(100)").append(");"); } else { sb.append("CREATE TABLE IF NOT EXISTS ").append(qi(getTableName())).append(" (").append(qi("id")) @@ -69,6 +71,8 @@ public String buildCreateTableSql(DbType dbType) { .append(qi("proxyBroadcastHandled")).append(" TINYINT(1) NOT NULL DEFAULT 0,") .append(qi("broadcastTargets")).append(" TEXT,").append(qi("broadcastForwardedServers")) .append(" TEXT,").append(qi("rewardDelivered")).append(" TINYINT(1) NOT NULL DEFAULT 0,") + .append(qi("httpDeliveryIds")).append(" TEXT,") + .append(qi("httpBroadcastDeliveryIds")).append(" TEXT,") .append(qi("server")).append(" VARCHAR(100),").append("INDEX idx_server (").append(qi("server")) .append("),").append("INDEX idx_uuid (").append(qi("uuid")).append("),").append("INDEX idx_time (") .append(qi("time")).append(")").append(") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;"); @@ -123,11 +127,13 @@ public boolean updateProxyBroadcastState(OfflineBungeeVote vote, String server) return false; } + boolean hasRowId = vote.getServerVoteCacheRowId() > 0; boolean hasVoteId = vote.getVoteId() != null; String sql = "UPDATE " + qi(getTableName()) + " SET " + qi("broadcastForwarded") + " = ?, " + qi("proxyBroadcastHandled") + " = ?, " + qi("broadcastTargets") + " = ?, " - + qi("broadcastForwardedServers") + " = ?, " + qi("rewardDelivered") + " = ? WHERE " - + (hasVoteId ? qi("voteid") + " = ? AND " + qi("server") + " = ?;" + + qi("broadcastForwardedServers") + " = ?, " + qi("rewardDelivered") + " = ?, " + + qi("httpDeliveryIds") + " = ?, " + qi("httpBroadcastDeliveryIds") + " = ? WHERE " + + (hasRowId ? qi("id") + " = ?;" : hasVoteId ? qi("voteid") + " = ? AND " + qi("server") + " = ?;" : qi("uuid") + " = ? AND " + qi("service") + " = ? AND " + qi("time") + " = ? AND " + qi("server") + " = ?;"); @@ -144,18 +150,22 @@ public boolean updateProxyBroadcastState(OfflineBungeeVote vote, String server) } ps.setString(3, vote.encodeBroadcastTargets()); ps.setString(4, vote.encodeBroadcastForwardedServers()); - if (hasVoteId) { - ps.setString(6, vote.getVoteId().toString()); - ps.setString(7, server); + ps.setString(6, vote.encodeHttpDeliveryIds()); + ps.setString(7, vote.encodeHttpBroadcastDeliveryIds()); + if (hasRowId) { + ps.setInt(8, vote.getServerVoteCacheRowId()); + } else if (hasVoteId) { + ps.setString(8, vote.getVoteId().toString()); + ps.setString(9, server); } else { if (getDbType() == DbType.POSTGRESQL) { - ps.setObject(6, UUID.fromString(vote.getUuid())); + ps.setObject(8, UUID.fromString(vote.getUuid())); } else { - ps.setString(6, vote.getUuid()); + ps.setString(8, vote.getUuid()); } - ps.setString(7, vote.getService()); - ps.setLong(8, vote.getTime()); - ps.setString(9, server); + ps.setString(9, vote.getService()); + ps.setLong(10, vote.getTime()); + ps.setString(11, server); } return ps.executeUpdate() > 0; } catch (SQLException | IllegalArgumentException e) { @@ -284,6 +294,8 @@ private void ensureProxyBroadcastStateColumns() { ensureColumn("broadcastTargets", "TEXT"); ensureColumn("broadcastForwardedServers", "TEXT"); ensureColumn("rewardDelivered", booleanType); + ensureColumn("httpDeliveryIds", "TEXT"); + ensureColumn("httpBroadcastDeliveryIds", "TEXT"); } private void ensureColumn(String column, String type) { @@ -333,16 +345,44 @@ private void ensureColumn(String column, String type) { */ public void insertVote(UUID voteId, String uuid, String playerName, String service, long time, boolean real, String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, String broadcastTargets, - String broadcastForwardedServers, boolean rewardDelivered, String server) { + String broadcastForwardedServers, boolean rewardDelivered, String httpDeliveryIds, + String httpBroadcastDeliveryIds, String server) { + tryInsertVote(voteId, uuid, playerName, service, time, real, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered, + httpDeliveryIds, httpBroadcastDeliveryIds, server); + } + + /** Inserts a vote and reports whether the database accepted it. */ + public boolean tryInsertVote(UUID voteId, String uuid, String playerName, String service, long time, boolean real, + String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, String broadcastTargets, + String broadcastForwardedServers, boolean rewardDelivered, String httpDeliveryIds, + String httpBroadcastDeliveryIds, String server) { + return tryInsertVoteAndGetId(voteId, uuid, playerName, service, time, real, text, broadcastForwarded, + proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, rewardDelivered, httpDeliveryIds, + httpBroadcastDeliveryIds, server) > 0; + } + + /** + * Inserts a vote and returns its durable primary key. Delivery-state updates + * must use this row identity because legacy cache rows need not have a vote ID. + * + * @return the generated primary key, or {@code -1} when insertion or key + * retrieval failed + */ + public int tryInsertVoteAndGetId(UUID voteId, String uuid, String playerName, String service, long time, + boolean real, String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, + String broadcastTargets, String broadcastForwardedServers, boolean rewardDelivered, + String httpDeliveryIds, String httpBroadcastDeliveryIds, String server) { String sql = "INSERT INTO " + qi(getTableName()) + " (" + qi("uuid") + ", " + qi("voteid") + ", " + qi("playerName") + ", " + qi("service") + ", " + qi("time") + ", " + qi("realVote") + ", " + qi("text") + ", " + qi("broadcastForwarded") + ", " + qi("proxyBroadcastHandled") + ", " + qi("broadcastTargets") + ", " + qi("broadcastForwardedServers") + ", " + qi("rewardDelivered") - + ", " + qi("server") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; + + ", " + qi("httpDeliveryIds") + ", " + qi("httpBroadcastDeliveryIds") + ", " + qi("server") + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);"; try (Connection conn = mysql.getConnectionManager().getConnection(); - PreparedStatement ps = conn.prepareStatement(sql)) { + PreparedStatement ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { if (getDbType() == DbType.POSTGRESQL) { ps.setObject(1, UUID.fromString(uuid)); @@ -373,11 +413,17 @@ public void insertVote(UUID voteId, String uuid, String playerName, String servi } ps.setString(10, broadcastTargets); ps.setString(11, broadcastForwardedServers); - ps.setString(13, server); + ps.setString(13, httpDeliveryIds); + ps.setString(14, httpBroadcastDeliveryIds); + ps.setString(15, server); - ps.executeUpdate(); + if (ps.executeUpdate() != 1) return -1; + try (ResultSet keys = ps.getGeneratedKeys()) { + return keys.next() ? keys.getInt(1) : -1; + } } catch (SQLException | IllegalArgumentException e) { debug(e); + return -1; } } @@ -505,25 +551,41 @@ public void removeVotesByServerAndUUID(String server, String uuid) { * @param server the server name */ public void removeVote(OfflineBungeeVote vote, String server) { - String sql = "DELETE FROM " + qi(getTableName()) + " WHERE " + qi("uuid") + " = ? AND " + qi("service") - + " = ? AND " + qi("time") + " = ? AND " + qi("server") + " = ?;"; + tryRemoveVote(vote, server); + } + + /** Removes one stable vote identity and reports whether the statement completed. */ + public boolean tryRemoveVote(OfflineBungeeVote vote, String server) { + boolean hasRowId = vote.getServerVoteCacheRowId() > 0; + boolean byVoteId = vote.getVoteId() != null; + String sql = "DELETE FROM " + qi(getTableName()) + " WHERE " + + (hasRowId ? qi("id") + " = ?" : byVoteId ? qi("voteid") + " = ?" : qi("uuid") + " = ? AND " + qi("service") + + " = ? AND " + qi("time") + " = ?") + + " AND " + qi("server") + " = ?;"; try (Connection conn = mysql.getConnectionManager().getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { - - if (getDbType() == DbType.POSTGRESQL) { - ps.setObject(1, UUID.fromString(vote.getUuid())); + if (hasRowId) { + ps.setInt(1, vote.getServerVoteCacheRowId()); + ps.setString(2, server); + } else if (byVoteId) { + ps.setString(1, vote.getVoteId().toString()); + ps.setString(2, server); } else { - ps.setString(1, vote.getUuid()); + if (getDbType() == DbType.POSTGRESQL) { + ps.setObject(1, UUID.fromString(vote.getUuid())); + } else { + ps.setString(1, vote.getUuid()); + } + ps.setString(2, vote.getService()); + ps.setLong(3, vote.getTime()); + ps.setString(4, server); } - - ps.setString(2, vote.getService()); - ps.setLong(3, vote.getTime()); - ps.setString(4, server); - ps.executeUpdate(); + return true; } catch (SQLException | IllegalArgumentException e) { debug(e); + return false; } } @@ -578,6 +640,8 @@ private List selectVotes(String sql, Object[] params) { rs.getString("text"), rs.getBoolean("broadcastForwarded"), rs.getBoolean("proxyBroadcastHandled"), rs.getString("broadcastTargets"), rs.getString("broadcastForwardedServers"), rs.getBoolean("rewardDelivered"), + rs.getString("httpDeliveryIds"), + rs.getString("httpBroadcastDeliveryIds"), rs.getString("server")); list.add(v); } @@ -605,6 +669,8 @@ public static class VoteRow { private final String broadcastTargets; private final String broadcastForwardedServers; private final boolean rewardDelivered; + private final String httpDeliveryIds; + private final String httpBroadcastDeliveryIds; private final String server; /** @@ -626,7 +692,8 @@ public static class VoteRow { */ public VoteRow(int id, String voteId, String uuid, String playerName, String service, long time, boolean realVote, String text, boolean broadcastForwarded, boolean proxyBroadcastHandled, - String broadcastTargets, String broadcastForwardedServers, boolean rewardDelivered, String server) { + String broadcastTargets, String broadcastForwardedServers, boolean rewardDelivered, String httpDeliveryIds, + String httpBroadcastDeliveryIds, String server) { this.id = id; this.voteId = voteId; this.uuid = uuid; @@ -640,6 +707,8 @@ public VoteRow(int id, String voteId, String uuid, String playerName, String ser this.broadcastTargets = broadcastTargets; this.broadcastForwardedServers = broadcastForwardedServers; this.rewardDelivered = rewardDelivered; + this.httpDeliveryIds = httpDeliveryIds; + this.httpBroadcastDeliveryIds = httpBroadcastDeliveryIds; this.server = server; } @@ -731,6 +800,14 @@ public boolean isRewardDelivered() { return rewardDelivered; } + public String getHttpDeliveryIds() { + return httpDeliveryIds; + } + + public String getHttpBroadcastDeliveryIds() { + return httpBroadcastDeliveryIds; + } + /** * Gets the server name. * @return the server name diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheDurability.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheDurability.java new file mode 100644 index 000000000..7b61ccb59 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheDurability.java @@ -0,0 +1,114 @@ +package com.bencodez.votingplugin.proxy.cache; + +import java.nio.file.Path; +import java.util.function.BooleanSupplier; + +import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Forces and reads back emergency JSON journal entries before they are exposed in memory. */ +public final class VoteCacheDurability { + public static final class ReloadFailedException extends IllegalStateException { + private static final long serialVersionUID = 1L; + + private ReloadFailedException(Throwable saveFailure, Throwable reloadFailure) { + super("Unable to restore the JSON vote journal after a failed durable save", saveFailure); + addSuppressed(reloadFailure); + } + } + + private VoteCacheDurability() { } + + public static boolean saveAndVerifyServerVote(IVoteCache cache, String server, int index, + OfflineBungeeVote expected) { + return saveAndVerify(cache, () -> cache.getServerVotes(server, String.valueOf(index)), expected); + } + + public static boolean saveAndVerifyOnlineVote(IVoteCache cache, String uuid, int index, + OfflineBungeeVote expected) { + return saveAndVerify(cache, () -> cache.getOnlineVotes(uuid, String.valueOf(index)), expected); + } + + public static boolean saveAndVerifyTimeVote(IVoteCache cache, int index, VoteTimeQueue expected) { + return saveAndVerify(cache, () -> cache.getTimedVoteCache(String.valueOf(index)), + data -> matches(data, expected)); + } + + /** Publishes a cache deletion and verifies the removed identity remains absent after reload. */ + public static boolean saveAndVerifyRemoval(IVoteCache cache, BooleanSupplier isAbsent) { + if (cache == null || isAbsent == null || cache.getStoragePath() == null) return false; + try { + try { + cache.saveDurably(); + } catch (DurableFiles.PublishedException published) { + // The replacement is active; verification below determines the result. + } + cache.reload(); + return isAbsent.getAsBoolean(); + } catch (RuntimeException | java.io.IOException failure) { + try { + cache.reload(); + } catch (RuntimeException reloadFailure) { + throw new ReloadFailedException(failure, reloadFailure); + } + return false; + } + } + + private static boolean saveAndVerify(IVoteCache cache, java.util.function.Supplier read, + OfflineBungeeVote expected) { + return saveAndVerify(cache, read, data -> matches(data, expected)); + } + + private static boolean saveAndVerify(IVoteCache cache, java.util.function.Supplier read, + java.util.function.Predicate verify) { + if (cache == null || verify == null) return false; + Path path = cache.getStoragePath(); + if (path == null) return false; + try { + try { + cache.saveDurably(); + } catch (DurableFiles.PublishedException published) { + // The new file is already active. Verify and keep its matching in-memory + // state rather than rolling back and later overwriting the publication. + } + cache.reload(); + return verify.test(read.get()); + } catch (RuntimeException | java.io.IOException failure) { + // addVote* mutates the JSON object before saveDurably runs. Discard that + // uncommitted mutation so a later retry reuses the same slot instead of + // eventually publishing both the failed attempt and its retry. + try { + cache.reload(); + } catch (RuntimeException reloadFailure) { + throw new ReloadFailedException(failure, reloadFailure); + } + return false; + } + } + + private static boolean matches(DataNode data, VoteTimeQueue expected) { + if (expected == null || data == null || !data.isObject() || !data.has("UUID") + || !data.has("Service") || !data.has("Time")) return false; + if (!expected.getUuid().equals(data.get("UUID").asString()) + || !expected.getService().equals(data.get("Service").asString()) + || expected.getTime() != data.get("Time").asLong()) return false; + if (expected.getVoteId() == null) return true; + String voteId = data.has("VoteId") ? data.get("VoteId").asString() + : data.has("VoteID") ? data.get("VoteID").asString() : ""; + return expected.getVoteId().toString().equals(voteId); + } + + private static boolean matches(DataNode data, OfflineBungeeVote expected) { + if (data == null || !data.isObject() || !data.has("UUID") || !data.has("Service") || !data.has("Time")) + return false; + if (!expected.getUuid().equals(data.get("UUID").asString()) + || !expected.getService().equals(data.get("Service").asString()) + || expected.getTime() != data.get("Time").asLong()) return false; + if (expected.getVoteId() == null) return true; + String voteId = data.has("VoteId") ? data.get("VoteId").asString() + : data.has("VoteID") ? data.get("VoteID").asString() : ""; + return expected.getVoteId().toString().equals(voteId); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java index 01c079413..20106e200 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VoteCacheHandler.java @@ -1,12 +1,18 @@ package com.bencodez.votingplugin.proxy.cache; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.ArrayList; import java.util.Collection; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Map.Entry; +import java.util.Objects; import java.util.Queue; import java.util.Set; import java.util.UUID; @@ -17,6 +23,7 @@ import com.bencodez.simpleapi.sql.mysql.config.MysqlConfig; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.util.DurableFiles; import lombok.Getter; @@ -24,6 +31,7 @@ * Handles caching of votes for proxy servers. */ public abstract class VoteCacheHandler { + private static final int MAX_PENDING_PERSISTENCE_VOTES = 1024; /** * Queue of timed votes for time change processing. @@ -37,6 +45,16 @@ public abstract class VoteCacheHandler { // server based private ConcurrentHashMap> cachedVotes = new ConcurrentHashMap<>(); + // Entries whose durable insert failed remain isolated from delivery until a + // retry succeeds. This prevents both silent loss and dispatch from memory + // before the cache/outbox record is durable. + private final ConcurrentHashMap> pendingOnlineVotePersistence = + new ConcurrentHashMap<>(); + private final ConcurrentHashMap> pendingServerVotePersistence = + new ConcurrentHashMap<>(); + private int pendingPersistenceVoteCount; + private boolean jsonStorageQuarantined; + /** * Checks if a server has cached votes. * @param server the server name @@ -92,26 +110,60 @@ public int getProxyCachedTotal(String uuid) { * @param vote the vote to add */ public synchronized void addServerVote(String server, OfflineBungeeVote vote) { - if (containsServerVote(server, vote.getVoteId())) { + addServerVoteDurably(server, vote); + } + + /** + * Adds a server vote only when it has been durably stored. MySQL failures use + * the JSON cache as an emergency journal when it is available. + * + * @param server the server name + * @param vote the vote to add + * @return true when the vote is durable (including an already cached vote) + */ + public synchronized boolean addServerVoteDurably(String server, OfflineBungeeVote vote) { + if (server == null || vote == null) { + return false; + } + if (containsServerVote(server, vote)) { debug1("Not caching duplicate vote " + vote.getVoteId() + " for server " + server); - return; + return true; } - cachedVotes.putIfAbsent(server, new ArrayList<>()); - cachedVotes.get(server).add(vote); - + boolean stored; if (useMySQL) { - voteCacheTable.insertVote(vote.getVoteId(), vote.getUuid(), vote.getPlayerName(), vote.getService(), + int rowId = voteCacheTable.tryInsertVoteAndGetId(vote.getVoteId(), vote.getUuid(), vote.getPlayerName(), vote.getService(), vote.getTime(), vote.isRealVote(), vote.getText(), vote.isBroadcastForwarded(), vote.isProxyBroadcastHandled(), vote.encodeBroadcastTargets(), - vote.encodeBroadcastForwardedServers(), vote.isRewardDelivered(), server); + vote.encodeBroadcastForwardedServers(), vote.isRewardDelivered(), vote.encodeHttpDeliveryIds(), + vote.encodeHttpBroadcastDeliveryIds(), server); + stored = rowId > 0; + if (stored) { + vote.setServerVoteCacheRowId(rowId); + } else { + stored = persistServerVoteToJson(server, vote); + } } else { - // IMPORTANT: index must come from JSON, not from cachedVotes (cache can be out - // of sync with JSON) - int idx = jsonStorage.getServerVotes(server).size(); - jsonStorage.addVote(server, idx, vote); - jsonStorage.save(); + stored = persistServerVoteToJson(server, vote); + } + if (stored) { + cachedVotes.putIfAbsent(server, new ArrayList<>()); + cachedVotes.get(server).add(vote); } + return stored; + } + + /** Retains a failed server-cache insert for a later durability retry. */ + public synchronized boolean retainServerVoteForPersistenceRetry(String server, OfflineBungeeVote vote) { + if (server == null || vote == null) return false; + ArrayList pending = pendingServerVotePersistence.get(server); + if (containsVoteId(pending, vote.getVoteId())) return true; + if (pendingPersistenceVoteCount >= MAX_PENDING_PERSISTENCE_VOTES) return false; + pendingServerVotePersistence.putIfAbsent(server, new ArrayList<>()); + pending = pendingServerVotePersistence.get(server); + pending.add(vote); + pendingPersistenceVoteCount++; + return true; } /** @@ -122,8 +174,17 @@ public synchronized void addServerVote(String server, OfflineBungeeVote vote) { */ public synchronized boolean updateServerVote(String server, OfflineBungeeVote vote) { if (useMySQL) { - return voteCacheTable.updateProxyBroadcastState(vote, server); + if (voteCacheTable.updateProxyBroadcastState(vote, server)) { + // SQL rows can have an emergency JSON twin after a mixed-version or + // recovery path. Keep that exact twin current before publishing again. + return !hasJsonServerVote(vote, server) || updateServerVoteJson(server, vote); + } + // Do not advance only an emergency JSON twin once this vote has a known + // SQL row. A later SQL retry could otherwise replay stale delivery state. + if (vote.getServerVoteCacheRowId() > 0) return false; + return updateServerVoteJson(server, vote); } + if (jsonStorage == null || jsonStorageQuarantined) return false; Collection keys = jsonStorage.getServerVotes(server); if (keys == null) { @@ -135,11 +196,12 @@ public synchronized boolean updateServerVote(String server, OfflineBungeeVote vo || !data.has("Time")) { continue; } - if (matchesStoredVote(data, vote)) { + if (matchesStoredServerVote(key, data, vote)) { try { - jsonStorage.addVote(server, Integer.parseInt(key), vote); - jsonStorage.save(); - return true; + int index = Integer.parseInt(key); + vote.setServerVoteCacheJsonKey(key); + jsonStorage.addVote(server, index, vote); + return verifyJsonServerVote(server, index, vote); } catch (RuntimeException e) { debug1(e); return false; @@ -158,13 +220,13 @@ public void removeVote(String server, String uuid) { if (cachedVotes.containsKey(server)) { ArrayList votes = cachedVotes.get(server); votes.removeIf(vote -> vote.getUuid().equals(uuid)); - - if (useMySQL) { - voteCacheTable.removeVotesByServerAndUUID(server, uuid); - } else { - jsonStorage.removeServerVote(server, uuid); - jsonStorage.save(); - } + } + if (useMySQL) { + voteCacheTable.removeVotesByServerAndUUID(server, uuid); + } + if (jsonStorage != null && !jsonStorageQuarantined) { + jsonStorage.removeServerVote(server, uuid); + jsonStorage.save(); } } @@ -176,7 +238,8 @@ public void removeVotes(String server) { cachedVotes.remove(server); if (useMySQL) { voteCacheTable.removeVotesByServer(server); - } else { + } + if (jsonStorage != null && !jsonStorageQuarantined) { jsonStorage.removeServerVotes(server); jsonStorage.save(); } @@ -215,26 +278,94 @@ public Set getOnlineVoteUUIDs() { * @param vote the vote to add */ public synchronized void addOnlineVote(String uuid, OfflineBungeeVote vote) { - if (containsOnlineVote(uuid, vote.getVoteId())) { + addOnlineVoteDurably(uuid, vote); + } + + /** + * Adds an online vote only when it has been durably stored. MySQL failures use + * the JSON cache as an emergency journal when it is available. + * + * @param uuid voter UUID + * @param vote the vote to add + * @return true when the vote is durable (including an already cached vote) + */ + public synchronized boolean addOnlineVoteDurably(String uuid, OfflineBungeeVote vote) { + if (uuid == null || vote == null) { + return false; + } + if (containsOnlineVote(uuid, vote)) { debug1("Not caching duplicate online vote " + vote.getVoteId() + " for " + uuid); - return; + return true; } - cachedOnlineVotes.putIfAbsent(uuid, new ArrayList<>()); - cachedOnlineVotes.get(uuid).add(vote); - + boolean stored; if (useMySQL) { - onlineVoteCacheTable.insertVote(vote.getVoteId(), vote.getUuid(), vote.getPlayerName(), vote.getService(), + int rowId = onlineVoteCacheTable.tryInsertVoteAndGetId(vote.getVoteId(), vote.getUuid(), vote.getPlayerName(), vote.getService(), vote.getTime(), vote.isRealVote(), vote.getText(), vote.isBroadcastForwarded(), vote.isProxyBroadcastHandled(), vote.encodeBroadcastTargets(), - vote.encodeBroadcastForwardedServers(), vote.isRewardDelivered()); + vote.encodeBroadcastForwardedServers(), vote.isRewardDelivered(), vote.encodeHttpDeliveryIds(), + vote.encodeHttpBroadcastDeliveryIds()); + stored = rowId > 0; + if (stored) { + vote.setOnlineVoteCacheRowId(rowId); + } else { + stored = persistOnlineVoteToJson(uuid, vote); + } } else { - // IMPORTANT: index must come from JSON, not from cachedOnlineVotes (cache can - // be out of sync with JSON) - int idx = jsonStorage.getOnlineVotes(uuid).size(); - jsonStorage.addVoteOnline(uuid, idx, vote); - jsonStorage.save(); + stored = persistOnlineVoteToJson(uuid, vote); + } + if (stored) { + cachedOnlineVotes.putIfAbsent(uuid, new ArrayList<>()); + cachedOnlineVotes.get(uuid).add(vote); + } + return stored; + } + + /** Retains a failed online-cache insert for a later durability retry. */ + public synchronized boolean retainOnlineVoteForPersistenceRetry(String uuid, OfflineBungeeVote vote) { + if (uuid == null || vote == null) return false; + ArrayList pending = pendingOnlineVotePersistence.get(uuid); + if (containsVoteId(pending, vote.getVoteId())) return true; + if (pendingPersistenceVoteCount >= MAX_PENDING_PERSISTENCE_VOTES) return false; + pendingOnlineVotePersistence.putIfAbsent(uuid, new ArrayList<>()); + pending = pendingOnlineVotePersistence.get(uuid); + pending.add(vote); + pendingPersistenceVoteCount++; + return true; + } + + /** + * Retries failed cache inserts. Entries become visible to normal delivery only + * after their ordinary durable insert succeeds. + */ + public synchronized boolean retryPendingVotePersistence() { + for (Map.Entry> entry + : new ArrayList<>(pendingServerVotePersistence.entrySet())) { + for (OfflineBungeeVote vote : new ArrayList<>(entry.getValue())) { + if (addServerVoteDurably(entry.getKey(), vote) && entry.getValue().remove(vote)) { + pendingPersistenceVoteCount--; + } + } + if (entry.getValue().isEmpty()) pendingServerVotePersistence.remove(entry.getKey()); + } + for (Map.Entry> entry + : new ArrayList<>(pendingOnlineVotePersistence.entrySet())) { + for (OfflineBungeeVote vote : new ArrayList<>(entry.getValue())) { + if (addOnlineVoteDurably(entry.getKey(), vote) && entry.getValue().remove(vote)) { + pendingPersistenceVoteCount--; + } + } + if (entry.getValue().isEmpty()) pendingOnlineVotePersistence.remove(entry.getKey()); + } + return pendingServerVotePersistence.isEmpty() && pendingOnlineVotePersistence.isEmpty(); + } + + private boolean containsVoteId(Collection votes, UUID voteId) { + if (votes == null || voteId == null) return false; + for (OfflineBungeeVote candidate : votes) { + if (candidate != null && voteId.equals(candidate.getVoteId())) return true; } + return false; } /** @@ -245,8 +376,15 @@ public synchronized void addOnlineVote(String uuid, OfflineBungeeVote vote) { */ public synchronized boolean updateOnlineVote(String uuid, OfflineBungeeVote vote) { if (useMySQL) { - return onlineVoteCacheTable.updateProxyBroadcastState(vote); + if (onlineVoteCacheTable.updateProxyBroadcastState(vote)) { + return !hasJsonOnlineVote(vote, uuid) || updateOnlineVoteJson(uuid, vote); + } + // Do not advance only an emergency JSON twin once this vote has a known + // SQL row. A later SQL retry could otherwise replay stale delivery state. + if (vote.getOnlineVoteCacheRowId() > 0) return false; + return updateOnlineVoteJson(uuid, vote); } + if (jsonStorage == null || jsonStorageQuarantined) return false; Collection keys = jsonStorage.getOnlineVotes(uuid); if (keys == null) { @@ -258,11 +396,12 @@ public synchronized boolean updateOnlineVote(String uuid, OfflineBungeeVote vote || !data.has("Time")) { continue; } - if (matchesStoredVote(data, vote)) { + if (matchesStoredOnlineVote(key, data, vote)) { try { - jsonStorage.addVoteOnline(uuid, Integer.parseInt(key), vote); - jsonStorage.save(); - return true; + int index = Integer.parseInt(key); + vote.setOnlineVoteCacheJsonKey(key); + jsonStorage.addVoteOnline(uuid, index, vote); + return verifyJsonOnlineVote(uuid, index, vote); } catch (RuntimeException e) { debug1(e); return false; @@ -284,17 +423,27 @@ public synchronized void clearOnlineVoteRewards(String uuid) { return; } - ArrayList retained = new ArrayList<>(); - for (OfflineBungeeVote vote : votes) { + for (OfflineBungeeVote vote : new ArrayList<>(votes)) { if (vote.isProxyBroadcastHandled() && !vote.isProxyBroadcastComplete()) { + if (!vote.isRewardDelivered()) { + vote.setRewardDelivered(true); + vote.setDeliveryStateDirty(true); + } + if (vote.isDeliveryStateDirty() && updateOnlineVote(uuid, vote)) vote.setDeliveryStateDirty(false); + continue; + } + // Another proxy has already delivered this reward. Persist that monotonic + // ineligible state before attempting deletion, so a transient delete + // failure cannot leave the retained row eligible for local delivery. + if (!vote.isRewardDelivered()) { vote.setRewardDelivered(true); - retained.add(vote); + vote.setDeliveryStateDirty(true); } - } - - removeOnlineVotes(uuid); - for (OfflineBungeeVote vote : retained) { - addOnlineVote(uuid, vote); + if (vote.isDeliveryStateDirty()) { + if (!updateOnlineVote(uuid, vote)) continue; + vote.setDeliveryStateDirty(false); + } + tryRemoveOnlineVote(uuid, vote); } } @@ -305,22 +454,73 @@ public synchronized void clearOnlineVoteRewards(String uuid) { * @param removedVote vote to remove */ public synchronized void removeOnlineVote(String uuid, OfflineBungeeVote removedVote) { + tryRemoveOnlineVote(uuid, removedVote); + } + + /** Removes one voter-keyed cached vote after every configured store confirms deletion. */ + public synchronized boolean tryRemoveOnlineVote(String uuid, OfflineBungeeVote removedVote) { ArrayList votes = cachedOnlineVotes.get(uuid); if (votes == null || votes.isEmpty()) { - return; + return true; + } + boolean mysqlRemoved = !useMySQL || onlineVoteCacheTable.tryRemoveVote(removedVote); + boolean jsonRemoved = jsonStorage == null; + if (jsonStorage != null && !jsonStorageQuarantined + && (!useMySQL || removedVote.getOnlineVoteCacheRowId() <= 0 + || removedVote.getOnlineVoteCacheJsonKey() != null)) { + jsonRemoved = removeJsonOnlineVoteDurably(uuid, removedVote); + } else if (jsonStorage != null && !jsonStorageQuarantined) { + // An unpaired SQL row has no exact JSON identity. Equal legacy fields do + // not prove that a JSON row is its emergency twin. + jsonRemoved = true; + } + boolean removedDurably = mysqlRemoved && jsonRemoved; + if (!removedDurably) return false; + votes.removeIf(vote -> sameVoteIdentity(vote, removedVote)); + if (votes.isEmpty()) cachedOnlineVotes.remove(uuid); + return true; + } + + protected boolean removeJsonOnlineVoteDurably(String uuid, OfflineBungeeVote removedVote) { + jsonStorage.removeOnlineVote(removedVote); + try { + return VoteCacheDurability.saveAndVerifyRemoval(jsonStorage, + () -> !containsStoredOnlineVote(uuid, removedVote)); + } catch (VoteCacheDurability.ReloadFailedException failure) { + jsonStorageQuarantined = true; + debug1(failure); + return false; } + } - ArrayList retained = new ArrayList<>(); - for (OfflineBungeeVote vote : votes) { - if (!sameVoteIdentity(vote, removedVote)) { - retained.add(vote); + /** Returns whether this vote has an exact voter-cache JSON emergency twin. */ + private boolean hasJsonOnlineVote(OfflineBungeeVote vote, String uuid) { + if (jsonStorage == null) return false; + if (jsonStorageQuarantined) return true; + try { + Collection keys = jsonStorage.getOnlineVotes(uuid); + if (keys == null) return false; + for (String key : keys) { + DataNode data = jsonStorage.getOnlineVotes(uuid, key); + if (data != null && data.isObject() && matchesStoredOnlineVote(key, data, vote)) return true; } + return false; + } catch (RuntimeException failure) { + debug1(failure); + // A JSON journal we cannot inspect may be the only stale twin after a + // restart. Do not claim a delivery-state update is durable. + return true; } + } - removeOnlineVotes(uuid); - for (OfflineBungeeVote vote : retained) { - addOnlineVote(uuid, vote); + private boolean containsStoredOnlineVote(String uuid, OfflineBungeeVote expected) { + Collection keys = jsonStorage.getOnlineVotes(uuid); + if (keys == null) return false; + for (String key : keys) { + DataNode data = jsonStorage.getOnlineVotes(uuid, key); + if (data != null && matchesStoredOnlineVote(key, data, expected)) return true; } + return false; } /** @@ -331,7 +531,8 @@ public void removeOnlineVotes(String uuid) { cachedOnlineVotes.remove(uuid); if (useMySQL) { onlineVoteCacheTable.removeVotesByUuid(uuid); - } else { + } + if (jsonStorage != null && !jsonStorageQuarantined) { jsonStorage.removeOnlineVotes(uuid); jsonStorage.save(); } @@ -357,61 +558,60 @@ public void checkVoteCacheTime(int voteCacheTime) { } removeOnlineVotes(expiredOnlineVotes); - // Collect expired server votes - ArrayList expiredServerVotes = new ArrayList<>(); + // Keep JSON entry keys scoped to their owning server. Numeric keys are reused + // independently below every VoteCache. node, so a combined removal + // list can otherwise delete an unrelated row with the same key. + Map> expiredServerVotes = new LinkedHashMap<>(); for (Entry> entry : cachedVotes.entrySet()) { - ArrayList votes = entry.getValue(); - for (OfflineBungeeVote vote : votes) { + for (OfflineBungeeVote vote : entry.getValue()) { if (vote.getTime() + (voteCacheTime * 24 * 60 * 60 * 1000L) < cTime) { debug1("Removing vote from cache: " + vote.toString()); - expiredServerVotes.add(vote); + expiredServerVotes.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()).add(vote); } } } - for (String server : cachedVotes.keySet()) { - removeServerVotes(server, expiredServerVotes); + for (Entry> entry : expiredServerVotes.entrySet()) { + removeServerVotes(entry.getKey(), entry.getValue()); } } - /** - * Checks whether a vote is already cached for a server. - * - * @param server target server - * @param voteId unique vote identifier - * @return true if the vote is already cached for the server - */ - private boolean containsServerVote(String server, UUID voteId) { - if (voteId == null) { - return false; - } + private boolean containsServerVote(String server, OfflineBungeeVote candidate) { for (OfflineBungeeVote vote : getVotes(server)) { - if (voteId.equals(vote.getVoteId())) { + if (sameVoteIdentity(vote, candidate)) { return true; } } return false; } - /** - * Checks whether a vote is already cached for an online player. - * - * @param uuid player UUID - * @param voteId unique vote identifier - * @return true if the vote is already cached for the player - */ - private boolean containsOnlineVote(String uuid, UUID voteId) { - if (voteId == null) { - return false; - } + private boolean containsOnlineVote(String uuid, OfflineBungeeVote candidate) { for (OfflineBungeeVote vote : getOnlineVotes(uuid)) { - if (voteId.equals(vote.getVoteId())) { + if (sameVoteIdentity(vote, candidate)) { return true; } } return false; } + private OfflineBungeeVote findServerVoteSqlTwin(String server, OfflineBungeeVote emergency) { + if (emergency.getVoteId() == null) return null; + for (OfflineBungeeVote candidate : getVotes(server)) { + if (candidate.getServerVoteCacheRowId() > 0 && candidate.getServerVoteCacheJsonKey() == null + && emergency.getVoteId().equals(candidate.getVoteId())) return candidate; + } + return null; + } + + private OfflineBungeeVote findOnlineVoteSqlTwin(String uuid, OfflineBungeeVote emergency) { + if (emergency.getVoteId() == null) return null; + for (OfflineBungeeVote candidate : getOnlineVotes(uuid)) { + if (candidate.getOnlineVoteCacheRowId() > 0 && candidate.getOnlineVoteCacheJsonKey() == null + && emergency.getVoteId().equals(candidate.getVoteId())) return candidate; + } + return null; + } + /** * Reads a vote identifier using the current key and the legacy key. * @@ -460,19 +660,189 @@ private boolean matchesStoredVote(DataNode data, OfflineBungeeVote vote) { && vote.getTime() == data.get("Time").asLong(); } + private boolean matchesStoredServerVote(String key, DataNode data, OfflineBungeeVote vote) { + if (vote.getServerVoteCacheJsonKey() != null) return vote.getServerVoteCacheJsonKey().equals(key); + String storedVoteId = readVoteId(data); + if (vote.getVoteId() != null && !storedVoteId.isEmpty()) { + return vote.getVoteId().toString().equals(storedVoteId); + } + // A SQL row is not a JSON emergency twin merely because a legacy tuple + // matches. Pre-ID rows are not unique by those fields. + if (vote.getServerVoteCacheRowId() > 0) return false; + return matchesStoredVote(data, vote); + } + + private boolean matchesStoredOnlineVote(String key, DataNode data, OfflineBungeeVote vote) { + if (vote.getOnlineVoteCacheJsonKey() != null) return vote.getOnlineVoteCacheJsonKey().equals(key); + String storedVoteId = readVoteId(data); + if (vote.getVoteId() != null && !storedVoteId.isEmpty()) { + return vote.getVoteId().toString().equals(storedVoteId); + } + if (vote.getOnlineVoteCacheRowId() > 0) return false; + return matchesStoredVote(data, vote); + } + + /** Persists a server vote in the JSON emergency journal. */ + private boolean persistServerVoteToJson(String server, OfflineBungeeVote vote) { + if (jsonStorage == null || jsonStorageQuarantined) { + return false; + } + if (!ensureEmergencyJournalMarker()) { + return false; + } + try { + Collection keys = jsonStorage.getServerVotes(server); + int index = nextCacheIndex(keys); + vote.setServerVoteCacheJsonKey(String.valueOf(index)); + jsonStorage.addVote(server, index, vote); + return verifyJsonServerVote(server, index, vote); + } catch (RuntimeException e) { + debug1(e); + return false; + } + } + + /** Persists an online vote in the JSON emergency journal. */ + private boolean persistOnlineVoteToJson(String uuid, OfflineBungeeVote vote) { + if (jsonStorage == null || jsonStorageQuarantined) { + return false; + } + if (!ensureEmergencyJournalMarker()) { + return false; + } + try { + Collection keys = jsonStorage.getOnlineVotes(uuid); + int index = nextCacheIndex(keys); + vote.setOnlineVoteCacheJsonKey(String.valueOf(index)); + jsonStorage.addVoteOnline(uuid, index, vote); + return verifyJsonOnlineVote(uuid, index, vote); + } catch (RuntimeException e) { + debug1(e); + return false; + } + } + + protected boolean verifyJsonServerVote(String server, int index, OfflineBungeeVote vote) { + try { + return VoteCacheDurability.saveAndVerifyServerVote(jsonStorage, server, index, vote); + } catch (VoteCacheDurability.ReloadFailedException failure) { + jsonStorageQuarantined = true; + debug1(failure); + return false; + } + } + + protected boolean verifyJsonOnlineVote(String uuid, int index, OfflineBungeeVote vote) { + try { + return VoteCacheDurability.saveAndVerifyOnlineVote(jsonStorage, uuid, index, vote); + } catch (VoteCacheDurability.ReloadFailedException failure) { + jsonStorageQuarantined = true; + debug1(failure); + return false; + } + } + + protected boolean verifyJsonTimeVote(int index, VoteTimeQueue vote) { + try { + return VoteCacheDurability.saveAndVerifyTimeVote(jsonStorage, index, vote); + } catch (VoteCacheDurability.ReloadFailedException failure) { + jsonStorageQuarantined = true; + debug1(failure); + return false; + } + } + + private int nextCacheIndex(Collection keys) { + int index = 0; + while (keys != null && keys.contains(String.valueOf(index))) { + index++; + } + return index; + } + + private boolean updateServerVoteJson(String server, OfflineBungeeVote vote) { + if (jsonStorage == null || jsonStorageQuarantined) { + return false; + } + Collection keys = jsonStorage.getServerVotes(server); + if (keys == null) { + return false; + } + for (String key : keys) { + DataNode data = jsonStorage.getServerVotes(server, key); + if (data != null && data.isObject() && matchesStoredServerVote(key, data, vote)) { + try { + int index = Integer.parseInt(key); + vote.setServerVoteCacheJsonKey(key); + jsonStorage.addVote(server, index, vote); + return verifyJsonServerVote(server, index, vote); + } catch (RuntimeException e) { + debug1(e); + return false; + } + } + } + return false; + } + + private boolean updateOnlineVoteJson(String uuid, OfflineBungeeVote vote) { + if (jsonStorage == null || jsonStorageQuarantined) { + return false; + } + Collection keys = jsonStorage.getOnlineVotes(uuid); + if (keys == null) { + return false; + } + for (String key : keys) { + DataNode data = jsonStorage.getOnlineVotes(uuid, key); + if (data != null && data.isObject() && matchesStoredOnlineVote(key, data, vote)) { + try { + int index = Integer.parseInt(key); + vote.setOnlineVoteCacheJsonKey(key); + jsonStorage.addVoteOnline(uuid, index, vote); + return verifyJsonOnlineVote(uuid, index, vote); + } catch (RuntimeException e) { + debug1(e); + return false; + } + } + } + return false; + } + private boolean sameVoteIdentity(OfflineBungeeVote first, OfflineBungeeVote second) { - if (first.getVoteId() != null && second.getVoteId() != null) { - return first.getVoteId().equals(second.getVoteId()); + if (first.getVoteId() != null || second.getVoteId() != null) { + return first.getVoteId() != null && first.getVoteId().equals(second.getVoteId()); + } + if (first.getServerVoteCacheRowId() > 0 || second.getServerVoteCacheRowId() > 0) { + return first.getServerVoteCacheRowId() > 0 + && first.getServerVoteCacheRowId() == second.getServerVoteCacheRowId(); + } + if (first.getOnlineVoteCacheRowId() > 0 || second.getOnlineVoteCacheRowId() > 0) { + return first.getOnlineVoteCacheRowId() > 0 + && first.getOnlineVoteCacheRowId() == second.getOnlineVoteCacheRowId(); + } + if (first.getServerVoteCacheJsonKey() != null || second.getServerVoteCacheJsonKey() != null) { + return Objects.equals(first.getServerVoteCacheJsonKey(), second.getServerVoteCacheJsonKey()); + } + if (first.getOnlineVoteCacheJsonKey() != null || second.getOnlineVoteCacheJsonKey() != null) { + return Objects.equals(first.getOnlineVoteCacheJsonKey(), second.getOnlineVoteCacheJsonKey()); } return first.getUuid().equals(second.getUuid()) && first.getService().equals(second.getService()) && first.getTime() == second.getTime(); } - private boolean matchesStoredTimeVote(DataNode data, VoteTimeQueue vote) { + private boolean matchesStoredTimeVote(String key, DataNode data, VoteTimeQueue vote) { + if (vote.getTimedVoteCacheJsonKey() != null) { + return vote.getTimedVoteCacheJsonKey().equals(key); + } String storedVoteId = readVoteId(data); if (vote.getVoteId() != null && storedVoteId != null && !storedVoteId.isEmpty()) { return vote.getVoteId().toString().equals(storedVoteId); } + // A SQL row is not an emergency JSON entry merely because its legacy + // fields happen to match. Without a JSON entry key, leave it alone. + if (vote.getTimedVoteCacheRowId() > 0) return false; return data.has("Name") && data.has("Service") && data.has("Time") && vote.getName().equals(data.get("Name").asString()) && vote.getService().equals(data.get("Service").asString()) @@ -483,7 +853,7 @@ private boolean matchesStoredTimeVote(DataNode data, VoteTimeQueue vote) { * Saves the vote cache to storage. */ public void saveVoteCache() { - if (!useMySQL) { + if (jsonStorage != null && !jsonStorageQuarantined) { jsonStorage.save(); } } @@ -497,16 +867,26 @@ public synchronized boolean addTimeVoteToCache(VoteTimeQueue vote) { if (vote == null) { return false; } + if (containsTimeVote(vote)) { + debug1("Not caching duplicate timed vote " + vote.getVoteId()); + return true; + } timeChangeQueue.add(vote); if (useMySQL) { - boolean stored = timedVoteCacheTable.insertTimedVote(vote.getVoteId(), vote.getUuid(), vote.getName(), vote.getService(), - vote.getTime(), vote.isProxyBroadcastHandled(), vote.encodeBroadcastTargets(), - vote.encodeBroadcastForwardedServers(), vote.getTotals(), vote.isProcessed()); + boolean stored = timedVoteCacheTable.insertTimedVote(vote); if (!stored) { - timeChangeQueue.remove(vote); + // Keep the ACK outbox durable even while SQL is unavailable. The JSON + // store is the same emergency journal used by the other cache lanes and + // is loaded again by loadJsonEmergencyVotes() after a restart. + stored = persistTimeVoteToJson(vote); + if (!stored) timeChangeQueue.remove(vote); } return stored; } + if (jsonStorage == null || jsonStorageQuarantined) { + timeChangeQueue.remove(vote); + return false; + } try { Collection keys = jsonStorage.getTimedVoteCache(); @@ -514,9 +894,11 @@ public synchronized boolean addTimeVoteToCache(VoteTimeQueue vote) { while (keys != null && keys.contains(String.valueOf(index))) { index++; } + vote.setTimedVoteCacheJsonKey(String.valueOf(index)); jsonStorage.addTimedVote(index, vote); - jsonStorage.save(); - return true; + if (verifyJsonTimeVote(index, vote)) return true; + timeChangeQueue.remove(vote); + return false; } catch (RuntimeException e) { timeChangeQueue.remove(vote); debug1(e); @@ -524,6 +906,45 @@ public synchronized boolean addTimeVoteToCache(VoteTimeQueue vote) { } } + private boolean containsTimeVote(VoteTimeQueue expected) { + for (VoteTimeQueue candidate : timeChangeQueue) { + if (candidate == null) continue; + if (expected.getVoteId() != null && expected.getVoteId().equals(candidate.getVoteId())) return true; + if (expected.getVoteId() == null && candidate.getVoteId() == null + && expected.getUuid().equals(candidate.getUuid()) + && expected.getService().equals(candidate.getService()) + && expected.getTime() == candidate.getTime()) return true; + } + return false; + } + + private VoteTimeQueue findSqlEmergencyTwin(VoteTimeQueue emergency) { + if (emergency.getVoteId() == null) return null; + for (VoteTimeQueue candidate : timeChangeQueue) { + if (candidate == null || candidate.getTimedVoteCacheRowId() <= 0 + || candidate.getTimedVoteCacheJsonKey() != null) continue; + if (emergency.getVoteId().equals(candidate.getVoteId())) return candidate; + } + return null; + } + + /** Persists a timed vote in the JSON emergency journal. */ + private boolean persistTimeVoteToJson(VoteTimeQueue vote) { + if (jsonStorage == null || jsonStorageQuarantined) return false; + if (!ensureEmergencyJournalMarker()) { + return false; + } + try { + int index = nextCacheIndex(jsonStorage.getTimedVoteCache()); + vote.setTimedVoteCacheJsonKey(String.valueOf(index)); + jsonStorage.addTimedVote(index, vote); + return verifyJsonTimeVote(index, vote); + } catch (RuntimeException failure) { + debug1(failure); + return false; + } + } + /** * Persists changed delivery state for a queued rollover vote. * @@ -532,8 +953,39 @@ public synchronized boolean addTimeVoteToCache(VoteTimeQueue vote) { */ public synchronized boolean updateTimeVote(VoteTimeQueue vote) { if (useMySQL) { - return timedVoteCacheTable.updateTimedVote(vote); + if (timedVoteCacheTable.updateTimedVote(vote)) { + return !hasJsonTimeVote(vote) || updateTimeVoteJson(vote); + } + if (vote.getTimedVoteCacheRowId() > 0) return false; + // A timed vote can have been admitted to the JSON emergency journal when + // its initial SQL insert failed. Keep delivery-state ACKs durable there + // until the SQL row is available again. + return updateTimeVoteJson(vote); } + return updateTimeVoteJson(vote); + } + + /** + * Durably upgrades a legacy timed row with the stable ID required by reliable + * multi-proxy delivery. The in-memory object is restored on failure so callers + * cannot mistake an unpersisted identifier for a durable one. + */ + public synchronized boolean assignLegacyTimeVoteId(VoteTimeQueue vote, UUID voteId) { + if (vote == null || voteId == null) return false; + if (vote.getVoteId() != null) return voteId.equals(vote.getVoteId()); + if (vote.getTimedVoteCacheRowId() <= 0 && vote.getTimedVoteCacheJsonKey() == null) return false; + vote.setVoteId(voteId); + boolean jsonPresent = vote.getTimedVoteCacheJsonKey() != null || hasJsonTimeVote(vote); + boolean jsonStored = !jsonPresent || updateTimeVoteJson(vote); + boolean primaryStored = !useMySQL || vote.getTimedVoteCacheRowId() <= 0 + || timedVoteCacheTable.assignLegacyTimedVoteId(vote, voteId); + if (primaryStored && jsonStored) return true; + vote.setVoteId(null); + return false; + } + + private boolean updateTimeVoteJson(VoteTimeQueue vote) { + if (jsonStorage == null || jsonStorageQuarantined) return false; Collection keys = jsonStorage.getTimedVoteCache(); if (keys == null) { @@ -541,11 +993,12 @@ public synchronized boolean updateTimeVote(VoteTimeQueue vote) { } for (String key : keys) { DataNode data = jsonStorage.getTimedVoteCache(key); - if (data != null && data.isObject() && matchesStoredTimeVote(data, vote)) { + if (data != null && data.isObject() && matchesStoredTimeVote(key, data, vote)) { try { - jsonStorage.addTimedVote(Integer.parseInt(key), vote); - jsonStorage.save(); - return true; + int index = Integer.parseInt(key); + vote.setTimedVoteCacheJsonKey(key); + jsonStorage.addTimedVote(index, vote); + return verifyJsonTimeVote(index, vote); } catch (NumberFormatException e) { debug1(e); return false; @@ -566,30 +1019,240 @@ public synchronized boolean updateTimeVote(VoteTimeQueue vote) { */ public synchronized boolean removeTimeVote(VoteTimeQueue vote) { if (useMySQL) { - if (!timedVoteCacheTable.removeVote(vote)) { - return false; + if (timedVoteCacheTable.removeVote(vote)) { + if (hasJsonTimeVote(vote) && !removeEmergencyTimeVoteJson(vote)) return false; + timeChangeQueue.remove(vote); + return true; } - timeChangeQueue.remove(vote); + if (vote.getTimedVoteCacheRowId() > 0) return false; + // The SQL row may never have existed when this vote was admitted to the + // JSON emergency journal. Remove that durable fallback before dropping the + // in-memory ACK outbox. + return removeEmergencyTimeVoteJson(vote); + } + return removeTimeVoteJson(vote); + } + + private boolean hasJsonTimeVote(VoteTimeQueue vote) { + if (jsonStorage == null) return false; + try { + Collection keys = jsonStorage.getTimedVoteCache(); + if (keys == null) return false; + for (String key : keys) { + DataNode data = jsonStorage.getTimedVoteCache(key); + if (data != null && data.isObject() && matchesStoredTimeVote(key, data, vote)) return true; + } + return false; + } catch (RuntimeException failure) { + debug1(failure); + // An unreadable emergency journal may contain the same vote. Fail closed + // instead of removing the only in-memory reference and replaying it later. return true; } + } + + private boolean removeEmergencyTimeVoteJson(VoteTimeQueue vote) { + if (jsonStorage == null || jsonStorageQuarantined) return false; + Collection keys = jsonStorage.getTimedVoteCache(); + if (keys == null) return false; + ArrayList remaining = new ArrayList<>(); + boolean found = false; + for (String key : keys) { + DataNode data = jsonStorage.getTimedVoteCache(key); + if (data == null || !data.isObject()) return false; + if (matchesStoredTimeVote(key, data, vote)) { + found = true; + continue; + } + VoteTimeQueue queued = decodeJsonTimedVote(key, data); + if (queued == null) return false; + remaining.add(queued); + } + if (!found) return false; + return rewriteJsonTimeVotesAndRemove(vote, remaining); + } + private boolean removeTimeVoteJson(VoteTimeQueue vote) { ArrayList remaining = new ArrayList<>(timeChangeQueue); remaining.remove(vote); + if (jsonStorage == null || jsonStorageQuarantined) return false; + return rewriteJsonTimeVotesAndRemove(vote, remaining); + } + + private boolean rewriteJsonTimeVotesAndRemove(VoteTimeQueue vote, Collection remaining) { try { jsonStorage.removeTimedVotes(); int index = 0; + Set usedKeys = new LinkedHashSet<>(); for (VoteTimeQueue queued : remaining) { - jsonStorage.addTimedVote(index++, queued); + String key = queued.getTimedVoteCacheJsonKey(); + if (key == null || !key.matches("\\d+") || !usedKeys.add(key)) { + while (usedKeys.contains(String.valueOf(index))) index++; + key = String.valueOf(index); + usedKeys.add(key); + } + int entryIndex = Integer.parseInt(key); + queued.setTimedVoteCacheJsonKey(key); + jsonStorage.addTimedVote(entryIndex, queued); + index = Math.max(index, entryIndex + 1); } - jsonStorage.save(); + boolean removedDurably = VoteCacheDurability.saveAndVerifyRemoval(jsonStorage, () -> { + Collection keys = jsonStorage.getTimedVoteCache(); + if (keys == null) return true; + for (String key : keys) { + DataNode data = jsonStorage.getTimedVoteCache(key); + if (data != null && data.isObject() && matchesStoredTimeVote(key, data, vote)) return false; + } + return true; + }); + if (!removedDurably) return false; timeChangeQueue.remove(vote); return true; + } catch (VoteCacheDurability.ReloadFailedException failure) { + jsonStorageQuarantined = true; + debug1(failure); + return false; } catch (RuntimeException e) { debug1(e); return false; } } + /** + * Records completed timed-vote side effects outside the mutable queue file. + * This tombstone closes the crash window where both updating and deleting the + * queue row fail after those side effects have already committed. + */ + public synchronized boolean markTimeVoteCompletedDurably(VoteTimeQueue vote) { + Path target = timeVoteCompletionPath(vote); + if (target == null) return false; + Path staged = null; + try { + Files.createDirectories(target.getParent()); + staged = Files.createTempFile(target.getParent(), ".completion-", ".tmp"); + Files.writeString(staged, timeVoteCompletionIdentity(vote), StandardCharsets.UTF_8); + try { + DurableFiles.publishStagedFile(staged, target); + } catch (DurableFiles.PublishedException published) { + // The tombstone is already visible; the read-back below is authoritative. + } + return hasTimeVoteCompletion(vote); + } catch (IOException | RuntimeException failure) { + debug1(failure); + return false; + } finally { + if (staged != null) { + try { Files.deleteIfExists(staged); } catch (IOException ignored) { } + } + } + } + + public synchronized boolean hasTimeVoteCompletion(VoteTimeQueue vote) { + Path target = timeVoteCompletionPath(vote); + if (target == null || !Files.isRegularFile(target)) return false; + try { + return timeVoteCompletionIdentity(vote).equals(Files.readString(target, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException failure) { + debug1(failure); + return false; + } + } + + public synchronized void clearTimeVoteCompletion(VoteTimeQueue vote) { + Path target = timeVoteCompletionPath(vote); + if (target == null) return; + try { + DurableFiles.deleteIfExists(target); + } catch (IOException | RuntimeException failure) { + debug1(failure); + } + } + + /** + * Records a consumed multi-proxy vote before the receiver drops its in-memory + * retry fence. This deliberately uses the JSON cache's sibling directory even + * when SQL is the primary cache, so it remains available during SQL recovery. + * + * @param voteId stable multi-proxy vote identity + * @return true when the completion record is durably readable + */ + public synchronized boolean markMultiProxyVoteCompletedDurably(UUID voteId) { + Path target = multiProxyVoteCompletionPath(voteId); + if (target == null) return false; + Path staged = null; + try { + Files.createDirectories(target.getParent()); + if (hasMultiProxyVoteCompletion(voteId)) return true; + staged = Files.createTempFile(target.getParent(), ".multiproxy-completion-", ".tmp"); + Files.writeString(staged, voteId.toString(), StandardCharsets.UTF_8); + try { + DurableFiles.publishStagedFile(staged, target); + } catch (DurableFiles.PublishedException published) { + // The record is visible; the read-back below decides whether it is usable. + } + if (!hasMultiProxyVoteCompletion(voteId)) return false; + // Sender outboxes have no expiry, so their matching receiver fences must + // remain for the same lifetime. Pruning by count can turn a late retry into + // a second reward after enough newer votes complete. + return true; + } catch (IOException | RuntimeException failure) { + debug1(failure); + return false; + } finally { + if (staged != null) { + try { Files.deleteIfExists(staged); } catch (IOException ignored) { } + } + } + } + + /** Checks whether a multi-proxy receiver completion survived this runtime. */ + public synchronized boolean hasMultiProxyVoteCompletion(UUID voteId) { + Path target = multiProxyVoteCompletionPath(voteId); + if (target == null || !Files.isRegularFile(target)) return false; + try { + return voteId.toString().equals(Files.readString(target, StandardCharsets.UTF_8)); + } catch (IOException | RuntimeException failure) { + debug1(failure); + return false; + } + } + + /** Idempotently removes a completion fence after its origin retired the outbox. */ + public synchronized boolean removeMultiProxyVoteCompletion(UUID voteId) { + Path target = multiProxyVoteCompletionPath(voteId); + if (target == null) return false; + try { + DurableFiles.deleteIfExists(target); + return !Files.exists(target); + } catch (IOException | RuntimeException failure) { + debug1(failure); + return false; + } + } + + private Path timeVoteCompletionPath(VoteTimeQueue vote) { + if (vote == null || jsonStorage == null || jsonStorage.getStoragePath() == null) return null; + Path storage = jsonStorage.getStoragePath().toAbsolutePath().normalize(); + Path name = storage.getFileName(); + if (name == null) return null; + UUID key = vote.getVoteId() != null ? vote.getVoteId() + : UUID.nameUUIDFromBytes(timeVoteCompletionIdentity(vote).getBytes(StandardCharsets.UTF_8)); + return storage.resolveSibling(name + ".completed-timed-votes").resolve(key.toString()); + } + + private Path multiProxyVoteCompletionPath(UUID voteId) { + if (voteId == null || jsonStorage == null || jsonStorage.getStoragePath() == null) return null; + Path storage = jsonStorage.getStoragePath().toAbsolutePath().normalize(); + Path name = storage.getFileName(); + if (name == null) return null; + return storage.resolveSibling(name + ".completed-multiproxy-votes").resolve(voteId.toString()); + } + + private String timeVoteCompletionIdentity(VoteTimeQueue vote) { + return (vote.getVoteId() == null ? "" : vote.getVoteId()) + "\n" + vote.getUuid() + "\n" + + vote.getName() + "\n" + vote.getService() + "\n" + vote.getTime(); + } + /** * Loads vote cache from storage. */ @@ -602,7 +1265,9 @@ public void load() { voteRow.getText(), voteRow.isBroadcastForwarded(), voteRow.isProxyBroadcastHandled(), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastTargets()), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastForwardedServers()), - voteRow.isRewardDelivered()); + voteRow.isRewardDelivered(), OfflineBungeeVote.decodeHttpDeliveryIds(voteRow.getHttpDeliveryIds()), + OfflineBungeeVote.decodeHttpBroadcastDeliveryIds(voteRow.getHttpBroadcastDeliveryIds())); + vote.setServerVoteCacheRowId(voteRow.getId()); String server = voteRow.getServer(); cachedVotes.putIfAbsent(server, new ArrayList<>()); cachedVotes.get(server).add(vote); @@ -615,7 +1280,9 @@ public void load() { voteRow.getText(), voteRow.isBroadcastForwarded(), voteRow.isProxyBroadcastHandled(), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastTargets()), VoteTimeQueue.decodeBroadcastForwardedServers(voteRow.getBroadcastForwardedServers()), - voteRow.isRewardDelivered()); + voteRow.isRewardDelivered(), OfflineBungeeVote.decodeHttpDeliveryIds(voteRow.getHttpDeliveryIds()), + OfflineBungeeVote.decodeHttpBroadcastDeliveryIds(voteRow.getHttpBroadcastDeliveryIds())); + vote.setOnlineVoteCacheRowId(voteRow.getId()); String player = vote.getUuid(); cachedOnlineVotes.putIfAbsent(player, new ArrayList<>()); cachedOnlineVotes.get(player).add(vote); @@ -628,37 +1295,36 @@ public void load() { timedVoteRow.getService(), timedVoteRow.getTime(), timedVoteRow.isProxyBroadcastHandled(), VoteTimeQueue.decodeBroadcastForwardedServers(timedVoteRow.getBroadcastTargets()), VoteTimeQueue.decodeBroadcastForwardedServers(timedVoteRow.getBroadcastForwardedServers()), - timedVoteRow.getTotals(), timedVoteRow.isProcessed(), timedVoteRow.getUuid()); + timedVoteRow.getTotals(), timedVoteRow.isProcessed(), + timedVoteRow.isMultiProxyForwardingHandled(), timedVoteRow.getUuid(), + VoteTimeQueue.decodeHttpBroadcastDeliveryIds(timedVoteRow.getHttpBroadcastDeliveryIds())); + voteTimeQueue.setMultiProxyForwardingRequired(timedVoteRow.isMultiProxyForwardingRequired()); + voteTimeQueue.setMultiProxyCapabilityDiscoveryPending( + timedVoteRow.isMultiProxyCapabilityDiscoveryPending()); + voteTimeQueue.setRealVote(timedVoteRow.isRealVote()); + voteTimeQueue.setMultiProxyOrigin(timedVoteRow.getMultiProxyOrigin() == null ? "" + : timedVoteRow.getMultiProxyOrigin()); + voteTimeQueue.setMultiProxyCompletionPending(timedVoteRow.isMultiProxyCompletionPending()); + voteTimeQueue.setMultiProxyRecipients( + VoteTimeQueue.decodeBroadcastForwardedServers(timedVoteRow.getMultiProxyRecipients())); + voteTimeQueue.setMultiProxyAcknowledgedServers( + VoteTimeQueue.decodeBroadcastForwardedServers(timedVoteRow.getMultiProxyAcknowledgedServers())); + voteTimeQueue.setMultiProxyLegacyPendingRecipients( + VoteTimeQueue.decodeBroadcastForwardedServers( + timedVoteRow.getMultiProxyLegacyPendingRecipients())); + voteTimeQueue.setTimedVoteCacheRowId(timedVoteRow.getId()); timedVotes.add(voteTimeQueue); }); timeChangeQueue.addAll(timedVotes); + loadJsonEmergencyVotes(); } else { try { for (String key : jsonStorage.getTimedVoteCache()) { DataNode data = jsonStorage.getTimedVoteCache(key); - if (data != null && data.isObject()) { - String name = data.has("Name") ? data.get("Name").asString() : ""; - String service = data.has("Service") ? data.get("Service").asString() : ""; - long time = data.has("Time") ? data.get("Time").asLong() : 0L; - UUID voteId = readUuid(data, "VoteId"); - String uuid = data.has("UUID") ? data.get("UUID").asString() : ""; - boolean proxyBroadcastHandled = data.has("ProxyBroadcastHandled") - && data.get("ProxyBroadcastHandled").asBoolean(); - String forwardedServers = data.has("BroadcastForwardedServers") - ? data.get("BroadcastForwardedServers").asString() - : ""; - String broadcastTargets = data.has("BroadcastTargets") - ? data.get("BroadcastTargets").asString() - : ""; - String totals = data.has("Totals") ? data.get("Totals").asString() : ""; - boolean processed = data.has("Processed") && data.get("Processed").asBoolean(); - - getTimeChangeQueue().add(new VoteTimeQueue(voteId, name, service, time, proxyBroadcastHandled, - VoteTimeQueue.decodeBroadcastForwardedServers(broadcastTargets), - VoteTimeQueue.decodeBroadcastForwardedServers(forwardedServers), totals, processed, uuid)); - } + VoteTimeQueue queuedVote = decodeJsonTimedVote(key, data); + if (queuedVote != null) getTimeChangeQueue().add(queuedVote); } } catch (Exception e) { @@ -690,14 +1356,21 @@ public void load() { String broadcastForwardedServers = data.has("BroadcastForwardedServers") ? data.get("BroadcastForwardedServers").asString() : ""; + String httpDeliveryIds = data.has("HttpDeliveryIds") + ? data.get("HttpDeliveryIds").asString() : ""; + String httpBroadcastDeliveryIds = data.has("HttpBroadcastDeliveryIds") + ? data.get("HttpBroadcastDeliveryIds").asString() : ""; boolean rewardDelivered = data.has("RewardDelivered") && data.get("RewardDelivered").asBoolean(); - votes.add(new OfflineBungeeVote(voteId, name, uuid, service, time, real, text, + OfflineBungeeVote vote = new OfflineBungeeVote(voteId, name, uuid, service, time, real, text, broadcastForwarded, proxyBroadcastHandled, VoteTimeQueue.decodeBroadcastForwardedServers(broadcastTargets), VoteTimeQueue.decodeBroadcastForwardedServers(broadcastForwardedServers), - rewardDelivered)); + rewardDelivered, OfflineBungeeVote.decodeHttpDeliveryIds(httpDeliveryIds), + OfflineBungeeVote.decodeHttpBroadcastDeliveryIds(httpBroadcastDeliveryIds)); + vote.setServerVoteCacheJsonKey(num); + votes.add(vote); } } cachedVotes.put(server, votes); @@ -731,14 +1404,21 @@ public void load() { String broadcastForwardedServers = data.has("BroadcastForwardedServers") ? data.get("BroadcastForwardedServers").asString() : ""; + String httpDeliveryIds = data.has("HttpDeliveryIds") + ? data.get("HttpDeliveryIds").asString() : ""; + String httpBroadcastDeliveryIds = data.has("HttpBroadcastDeliveryIds") + ? data.get("HttpBroadcastDeliveryIds").asString() : ""; boolean rewardDelivered = data.has("RewardDelivered") && data.get("RewardDelivered").asBoolean(); - votes.add(new OfflineBungeeVote(voteId, name, uuid, service, time, real, text, + OfflineBungeeVote vote = new OfflineBungeeVote(voteId, name, uuid, service, time, real, text, broadcastForwarded, proxyBroadcastHandled, VoteTimeQueue.decodeBroadcastForwardedServers(broadcastTargets), VoteTimeQueue.decodeBroadcastForwardedServers(broadcastForwardedServers), - rewardDelivered)); + rewardDelivered, OfflineBungeeVote.decodeHttpDeliveryIds(httpDeliveryIds), + OfflineBungeeVote.decodeHttpBroadcastDeliveryIds(httpBroadcastDeliveryIds)); + vote.setOnlineVoteCacheJsonKey(num); + votes.add(vote); } } cachedOnlineVotes.put(player, votes); @@ -763,6 +1443,171 @@ public void load() { } + /** + * Loads entries written to the JSON emergency journal while MySQL was + * unavailable. Entries already present in SQL are de-duplicated only by vote + * ID. Legacy entries without an ID remain distinct because equal fields cannot + * prove that two durable rows represent the same vote. + */ + private void loadJsonEmergencyVotes() { + if (jsonStorage == null || !jsonStorage.hasEmergencyJournalMarker()) { + return; + } + try { + // Timed votes use the JSON store as an emergency outbox when the SQL + // insert failed. Merge them before the ordinary cache lanes so an ACK + // outbox is not lost across a proxy restart. + Collection timedKeys = jsonStorage.getTimedVoteCache(); + if (timedKeys != null) { + for (String key : timedKeys) { + DataNode data = jsonStorage.getTimedVoteCache(key); + VoteTimeQueue vote = decodeJsonTimedVote(key, data); + if (vote == null) continue; + VoteTimeQueue sqlTwin = findSqlEmergencyTwin(vote); + if (sqlTwin != null) { + // A failed/uncertain SQL insert can leave the same durable vote in + // both stores. Bind this exact emergency entry to one SQL row so + // later state updates and deletion clean up both copies without + // collapsing separate identical rows. + sqlTwin.setTimedVoteCacheJsonKey(key); + } else { + timeChangeQueue.add(vote); + } + } + } + Collection servers = jsonStorage.getServers(); + if (servers != null) { + for (String server : servers) { + Collection keys = jsonStorage.getServerVotes(server); + if (keys == null) { + continue; + } + for (String key : keys) { + OfflineBungeeVote vote = decodeJsonVote(jsonStorage.getServerVotes(server, key)); + if (vote == null) continue; + vote.setServerVoteCacheJsonKey(key); + OfflineBungeeVote sqlTwin = findServerVoteSqlTwin(server, vote); + if (sqlTwin != null) sqlTwin.setServerVoteCacheJsonKey(key); + else cachedVotes.computeIfAbsent(server, ignored -> new ArrayList<>()).add(vote); + } + } + } + Collection players = jsonStorage.getPlayers(); + if (players != null) { + for (String player : players) { + Collection keys = jsonStorage.getOnlineVotes(player); + if (keys == null) { + continue; + } + for (String key : keys) { + OfflineBungeeVote vote = decodeJsonVote(jsonStorage.getOnlineVotes(player, key)); + if (vote == null) continue; + vote.setOnlineVoteCacheJsonKey(key); + OfflineBungeeVote sqlTwin = findOnlineVoteSqlTwin(player, vote); + if (sqlTwin != null) sqlTwin.setOnlineVoteCacheJsonKey(key); + else cachedOnlineVotes.computeIfAbsent(player, ignored -> new ArrayList<>()).add(vote); + } + } + } + } catch (RuntimeException e) { + debug1(e); + } + } + + private boolean ensureEmergencyJournalMarker() { + if (jsonStorage == null) { + return false; + } + if (jsonStorage.hasEmergencyJournalMarker()) { + return true; + } + try { + if (useMySQL) { + // During MySQL recovery, only trust post-mark emergency data by discarding + // stale legacy JSON cache rows before the first durable marker write. + jsonStorage.clearData(); + jsonStorage.saveDurably(); + } + jsonStorage.markEmergencyJournalUsed(); + return true; + } catch (IOException | RuntimeException failure) { + debug1(failure); + return false; + } + } + + private VoteTimeQueue decodeJsonTimedVote(String key, DataNode data) { + if (data == null || !data.isObject()) return null; + String name = data.has("Name") ? data.get("Name").asString() : ""; + String service = data.has("Service") ? data.get("Service").asString() : ""; + long time = data.has("Time") ? data.get("Time").asLong() : 0L; + UUID voteId = readUuid(data, "VoteId"); + String uuid = data.has("UUID") ? data.get("UUID").asString() : ""; + boolean proxyBroadcastHandled = data.has("ProxyBroadcastHandled") + && data.get("ProxyBroadcastHandled").asBoolean(); + String forwardedServers = data.has("BroadcastForwardedServers") + ? data.get("BroadcastForwardedServers").asString() : ""; + String broadcastTargets = data.has("BroadcastTargets") ? data.get("BroadcastTargets").asString() : ""; + String totals = data.has("Totals") ? data.get("Totals").asString() : ""; + boolean processed = data.has("Processed") && data.get("Processed").asBoolean(); + boolean multiProxyForwardingHandled = data.has("MultiProxyForwardingHandled") + && data.get("MultiProxyForwardingHandled").asBoolean(); + String httpBroadcastDeliveryIds = data.has("HttpBroadcastDeliveryIds") + ? data.get("HttpBroadcastDeliveryIds").asString() : ""; + VoteTimeQueue queuedVote = new VoteTimeQueue(voteId, name, service, time, proxyBroadcastHandled, + VoteTimeQueue.decodeBroadcastForwardedServers(broadcastTargets), + VoteTimeQueue.decodeBroadcastForwardedServers(forwardedServers), totals, processed, + multiProxyForwardingHandled, uuid, + VoteTimeQueue.decodeHttpBroadcastDeliveryIds(httpBroadcastDeliveryIds)); + queuedVote.setMultiProxyForwardingRequired(data.has("MultiProxyForwardingRequired") + && data.get("MultiProxyForwardingRequired").asBoolean()); + queuedVote.setMultiProxyCapabilityDiscoveryPending(data.has("MultiProxyCapabilityDiscoveryPending") + && data.get("MultiProxyCapabilityDiscoveryPending").asBoolean()); + queuedVote.setRealVote(!data.has("RealVote") || data.get("RealVote").asBoolean()); + queuedVote.setMultiProxyOrigin(data.has("MultiProxyOrigin") + ? data.get("MultiProxyOrigin").asString() : ""); + queuedVote.setMultiProxyCompletionPending(data.has("MultiProxyCompletionPending") + && data.get("MultiProxyCompletionPending").asBoolean()); + queuedVote.setMultiProxyRecipients(VoteTimeQueue.decodeBroadcastForwardedServers( + data.has("MultiProxyRecipients") ? data.get("MultiProxyRecipients").asString() : "")); + queuedVote.setMultiProxyAcknowledgedServers(VoteTimeQueue.decodeBroadcastForwardedServers( + data.has("MultiProxyAcknowledgedServers") + ? data.get("MultiProxyAcknowledgedServers").asString() : "")); + queuedVote.setMultiProxyLegacyPendingRecipients(VoteTimeQueue.decodeBroadcastForwardedServers( + data.has("MultiProxyLegacyPendingRecipients") + ? data.get("MultiProxyLegacyPendingRecipients").asString() : "")); + queuedVote.setTimedVoteCacheJsonKey(key); + return queuedVote; + } + + private OfflineBungeeVote decodeJsonVote(DataNode data) { + if (data == null || !data.isObject()) { + return null; + } + String name = data.has("Name") ? data.get("Name").asString() : ""; + String uuid = data.has("UUID") ? data.get("UUID").asString() : ""; + String service = data.has("Service") ? data.get("Service").asString() : ""; + long time = data.has("Time") ? data.get("Time").asLong() : 0L; + boolean real = data.has("Real") && data.get("Real").asBoolean(); + String text = data.has("Text") ? data.get("Text").asString() : ""; + String voteId = readVoteId(data); + boolean broadcastForwarded = data.has("BroadcastForwarded") && data.get("BroadcastForwarded").asBoolean(); + boolean proxyBroadcastHandled = data.has("ProxyBroadcastHandled") + && data.get("ProxyBroadcastHandled").asBoolean(); + String broadcastTargets = data.has("BroadcastTargets") ? data.get("BroadcastTargets").asString() : ""; + String broadcastForwardedServers = data.has("BroadcastForwardedServers") + ? data.get("BroadcastForwardedServers").asString() : ""; + String httpDeliveryIds = data.has("HttpDeliveryIds") ? data.get("HttpDeliveryIds").asString() : ""; + String httpBroadcastDeliveryIds = data.has("HttpBroadcastDeliveryIds") + ? data.get("HttpBroadcastDeliveryIds").asString() : ""; + boolean rewardDelivered = data.has("RewardDelivered") && data.get("RewardDelivered").asBoolean(); + return new OfflineBungeeVote(voteId, name, uuid, service, time, real, text, broadcastForwarded, + proxyBroadcastHandled, VoteTimeQueue.decodeBroadcastForwardedServers(broadcastTargets), + VoteTimeQueue.decodeBroadcastForwardedServers(broadcastForwardedServers), rewardDelivered, + OfflineBungeeVote.decodeHttpDeliveryIds(httpDeliveryIds), + OfflineBungeeVote.decodeHttpBroadcastDeliveryIds(httpBroadcastDeliveryIds)); + } + private final boolean useMySQL; private ProxyVoteCacheTable voteCacheTable; private ProxyTimedVoteCacheTable timedVoteCacheTable; @@ -812,6 +1657,9 @@ public void load() { public VoteCacheHandler(MysqlConfig mysqlConfig, boolean useMySQL, boolean useExistingConnection, MySQL mysql, boolean debug, IVoteCache jsonStorage) { this.useMySQL = useMySQL; + // Keep the JSON store available as a durable emergency journal even when + // MySQL is the primary cache backend. + this.jsonStorage = jsonStorage; if (useMySQL) { if (useExistingConnection) { @@ -955,8 +1803,6 @@ public void debug(String text) { } }; } - } else { - this.jsonStorage = jsonStorage; } } @@ -973,39 +1819,76 @@ public String[] getCachedVotesServers() { * @param server the server name * @param removed list of votes to remove */ - public void removeServerVotes(String server, ArrayList removed) { + public synchronized void removeServerVotes(String server, ArrayList removed) { for (OfflineBungeeVote vote : removed) { - for (Map.Entry> entry : cachedVotes.entrySet()) { - if (entry.getKey().equals(server)) { - entry.getValue().removeIf(v -> v.getUuid().equals(vote.getUuid()) - && v.getService().equals(vote.getService()) && v.getTime() == vote.getTime()); - } + boolean mysqlRemoved = !useMySQL || voteCacheTable.tryRemoveVote(vote, server); + boolean jsonRemoved = jsonStorage == null; + if (jsonStorage != null && !jsonStorageQuarantined + && (!useMySQL || vote.getServerVoteCacheRowId() <= 0 + || vote.getServerVoteCacheJsonKey() != null)) { + jsonRemoved = removeJsonServerVoteDurably(server, vote); + } else if (jsonStorage != null && !jsonStorageQuarantined) { + // An unpaired SQL row has no exact JSON identity. Equal legacy fields do + // not prove that a JSON row is its emergency twin. + jsonRemoved = true; } - if (useMySQL) { - voteCacheTable.removeVote(vote, server); - } else { - jsonStorage.removeVote(server, vote); - jsonStorage.save(); + boolean removedDurably = mysqlRemoved && jsonRemoved; + if (!removedDurably) continue; + ArrayList serverVotes = cachedVotes.get(server); + if (serverVotes != null) { + serverVotes.removeIf(candidate -> sameVoteIdentity(candidate, vote)); + if (serverVotes.isEmpty()) cachedVotes.remove(server); + } + } + } + + protected boolean removeJsonServerVoteDurably(String server, OfflineBungeeVote vote) { + jsonStorage.removeVote(server, vote); + try { + return VoteCacheDurability.saveAndVerifyRemoval(jsonStorage, + () -> !containsStoredServerVote(server, vote)); + } catch (VoteCacheDurability.ReloadFailedException failure) { + jsonStorageQuarantined = true; + debug1(failure); + return false; + } + } + + /** Returns whether this vote has an exact server-cache JSON emergency twin. */ + private boolean hasJsonServerVote(OfflineBungeeVote vote, String server) { + if (jsonStorage == null) return false; + if (jsonStorageQuarantined) return true; + try { + Collection keys = jsonStorage.getServerVotes(server); + if (keys == null) return false; + for (String key : keys) { + DataNode data = jsonStorage.getServerVotes(server, key); + if (data != null && data.isObject() && matchesStoredServerVote(key, data, vote)) return true; } + return false; + } catch (RuntimeException failure) { + debug1(failure); + return true; } } + private boolean containsStoredServerVote(String server, OfflineBungeeVote expected) { + Collection keys = jsonStorage.getServerVotes(server); + if (keys == null) return false; + for (String key : keys) { + DataNode data = jsonStorage.getServerVotes(server, key); + if (data != null && matchesStoredServerVote(key, data, expected)) return true; + } + return false; + } + /** * Removes specific votes from the online vote cache. * @param removed list of votes to remove */ public void removeOnlineVotes(ArrayList removed) { - for (OfflineBungeeVote vote : removed) { - for (Map.Entry> entry : cachedOnlineVotes.entrySet()) { - entry.getValue().removeIf(v -> v.getUuid().equals(vote.getUuid()) - && v.getService().equals(vote.getService()) && v.getTime() == vote.getTime()); - } - if (useMySQL) { - onlineVoteCacheTable.removeVote(vote); - } else { - jsonStorage.removeOnlineVote(vote); - jsonStorage.save(); - } + for (OfflineBungeeVote vote : new ArrayList<>(removed)) { + tryRemoveOnlineVote(vote.getUuid(), vote); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VotePartyCacheDurability.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VotePartyCacheDurability.java new file mode 100644 index 000000000..8fb999c4e --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/cache/VotePartyCacheDurability.java @@ -0,0 +1,99 @@ +package com.bencodez.votingplugin.proxy.cache; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.bencodez.votingplugin.util.DurableFiles; + +/** Verifies that the cache library actually persisted the vote-party transaction. */ +public final class VotePartyCacheDurability { + private VotePartyCacheDurability() { } + + public static void saveAndVerify(Path file, IVoteCache cache) throws IOException { + Map> expectedPending = pending(cache); + int expectedVotes = cache.getVotePartyCurrentVotes(); + int expectedIncrease = cache.getVotePartyInreaseVotesRequired(); + PendingVotePartyProxyEffects expectedProxyEffects = cache.getPendingVotePartyProxyEffects(); + PendingVotePartyProxyEffects expectedQuarantinedEffects = cache.getQuarantinedVotePartyProxyEffects(); + try { + cache.saveDurably(); + } catch (DurableFiles.PublishedException published) { + // The replacement is installed; validate it instead of letting callers + // restore stale in-memory state over the published file. + } + try { + JsonObject root = JsonParser.parseString(Files.readString(file, StandardCharsets.UTF_8)).getAsJsonObject(); + JsonObject voteParty = object(root.get("VoteParty")); + if (integer(voteParty, "CurrentVotes") != expectedVotes + || integer(voteParty, "IncreaseVotes") != expectedIncrease + || !pending(voteParty).equals(expectedPending) + || !proxyEffects(voteParty, "PendingProxyEffects").equals(expectedProxyEffects) + || !proxyEffects(voteParty, "QuarantinedProxyEffects").equals(expectedQuarantinedEffects)) + throw new IOException("Vote-party cache state was not persisted"); + } catch (IOException failure) { + throw failure; + } catch (RuntimeException invalid) { + throw new IOException("Vote-party cache state is unreadable", invalid); + } + } + + private static PendingVotePartyProxyEffects proxyEffects(JsonObject voteParty, String name) { + JsonObject effects = object(voteParty.get(name)); + String broadcast = string(effects, "Broadcast"); + java.util.List commands = new java.util.ArrayList<>(); + JsonElement encodedCommands = effects.get("Commands"); + if (encodedCommands != null && !encodedCommands.isJsonNull()) { + for (JsonElement command : encodedCommands.getAsJsonArray()) commands.add(command.getAsString()); + } + return new PendingVotePartyProxyEffects(broadcast, commands); + } + + private static Map> pending(IVoteCache cache) { + Map> pending = new HashMap<>(); + var servers = cache.getPendingVotePartyRewardServers(); + if (servers == null) return pending; + for (String server : servers) { + var ids = cache.getPendingVotePartyRewardIds(server); + if (ids != null && !ids.isEmpty()) pending.put(server, new HashSet<>(ids)); + } + return pending; + } + + private static Map> pending(JsonObject voteParty) { + Map> pending = new HashMap<>(); + JsonObject encodedServers = object(voteParty.get("PendingRewards")); + for (Map.Entry server : encodedServers.entrySet()) { + String serverId = new String(Base64.getUrlDecoder().decode(server.getKey()), StandardCharsets.UTF_8); + JsonObject rewards = object(server.getValue()); + Set ids = new HashSet<>(); + for (Map.Entry reward : rewards.entrySet()) + if (reward.getValue().isJsonPrimitive() && reward.getValue().getAsBoolean()) ids.add(reward.getKey()); + if (!ids.isEmpty()) pending.put(serverId, ids); + } + return pending; + } + + private static JsonObject object(JsonElement element) { + return element == null || element.isJsonNull() ? new JsonObject() : element.getAsJsonObject(); + } + + private static int integer(JsonObject object, String name) { + JsonElement value = object.get(name); + return value == null || value.isJsonNull() ? 0 : value.getAsInt(); + } + + private static String string(JsonObject object, String name) { + JsonElement value = object.get(name); + return value == null || value.isJsonNull() ? "" : value.getAsString(); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java index aa1f4fa40..cd8b8c27f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ControlConnector.java @@ -2,6 +2,7 @@ import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -22,6 +23,8 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadLocalRandom; @@ -33,13 +36,16 @@ import java.util.function.Supplier; import java.util.regex.Pattern; +import com.bencodez.votingplugin.control.PluginDeploymentService; import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.control.HostedControlManager.HostConfiguration; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; import com.bencodez.votingplugin.proxy.presence.BackendPresenceStatus; import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.Route; import com.bencodez.votingplugin.proxy.control.ProxyControlResultStore.StoredResult; import com.bencodez.votingplugin.util.BoundedHttpBodyHandler; import com.bencodez.votingplugin.util.ControlCredentialFile; +import com.bencodez.votingplugin.util.DurableFiles; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -59,10 +65,12 @@ public final class ControlConnector implements AutoCloseable { private static final String COMMUNICATION_TEST_CAPABILITY = "config.transport-test.v1"; private static final String COMMUNICATION_TEST_PRESET = "communication-test"; private static final String PROXY_METHOD_CAPABILITY = "config.proxy-method.v1"; + private static final String PROXY_METHOD_HTTP_CAPABILITY = "config.proxy-method.v2"; private static final String PROXY_FILE_CAPABILITY = "config.proxy-files.v1"; private static final String PROXY_METHOD_PRESET = "proxy-method"; private static final String INTERNAL_OPERATION_TYPE = "_controlOperationType"; private static final String INTERNAL_REQUIRED_CAPABILITY = "_controlRequiredCapability"; + private static final Set DEPLOYMENT_TASK_FIELDS = Set.of("deploymentId", "artifactId", "sha256", "size", "attemptId"); private static final long OPERATION_POLL_MILLIS = 1000; private static final long MAX_BACKOFF_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final long OPERATION_SHUTDOWN_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(65); @@ -77,6 +85,11 @@ public final class ControlConnector implements AutoCloseable { private final ProxyRoutingConfigurationService configurationService; private final ProxyMethodConfigurationService methodConfigurationService; private final ProxyConfigurationFileService fileConfigurationService; + private final PluginDeploymentService deployments; + private final HttpClient deploymentHttp; + private final String deploymentCredential; + private final boolean directLocalDeploymentEndpoint; + private final ExecutorService deploymentExecutor; private final Function> communicationTest; private final Runnable runtimeReplacement; private final Path dataDirectory; @@ -91,12 +104,17 @@ public final class ControlConnector implements AutoCloseable { private volatile boolean closed; private volatile boolean registered; private volatile boolean configurationAccepted; + private volatile boolean deploymentsAccepted; private volatile Set acceptedCapabilities = Set.of(); private volatile int failures; private volatile long snapshotSequence; private volatile ScheduledFuture scheduled; private volatile ScheduledFuture operationPolling; + private volatile ScheduledFuture deploymentPolling; private volatile CompletableFuture activeRequest; + /* The deployment executor may discard queued AsyncSupply work during shutdown. + * Retain its future so close() can complete the dependent operation chain. */ + private volatile CompletableFuture activeDeploymentWork; private volatile CompletableFuture activeOperation; private volatile Status status = Status.STARTING; @@ -130,6 +148,19 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Function> communicationTest, ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, ProxyConfigurationFileService fileConfigurationService) { + this(settings, scheduler, transport, snapshotSource, logger, sessionId, jitterSource, configurationService, + dataDirectory, route, recovering, recoveryComplete, communicationTest, methodConfigurationService, + runtimeReplacement, fileConfigurationService, null, null, null, false); + } + + private ControlConnector(Settings settings, ScheduledExecutorService scheduler, Transport transport, + Supplier> snapshotSource, Consumer logger, UUID sessionId, + LongSupplier jitterSource, ProxyRoutingConfigurationService configurationService, Path dataDirectory, + Route route, boolean recovering, Runnable recoveryComplete, + Function> communicationTest, + ProxyMethodConfigurationService methodConfigurationService, Runnable runtimeReplacement, + ProxyConfigurationFileService fileConfigurationService, PluginDeploymentService deployments, + HttpClient deploymentHttp, String deploymentCredential, boolean directLocalDeploymentEndpoint) { this.settings = Objects.requireNonNull(settings, "settings"); this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); this.transport = Objects.requireNonNull(transport, "transport"); @@ -142,6 +173,15 @@ private ControlConnector(Settings settings, ScheduledExecutorService scheduler, this.communicationTest = communicationTest; this.runtimeReplacement = runtimeReplacement; this.fileConfigurationService = fileConfigurationService; + this.deployments = deployments; + this.deploymentHttp = deploymentHttp; + this.deploymentCredential = deploymentCredential; + this.directLocalDeploymentEndpoint = directLocalDeploymentEndpoint; + this.deploymentExecutor = deployments == null ? null : Executors.newSingleThreadExecutor(runnable -> { + Thread thread = new Thread(runnable, "votingplugin-control-proxy-deployment"); + thread.setDaemon(true); + return thread; + }); this.dataDirectory = dataDirectory; this.route = route; this.recovering = recovering; @@ -192,6 +232,7 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio } } boolean recovering = recovered != null && recovered.routeRequired(); + boolean deploymentRouteCurrent = config.getControlEnabled() && !recovering; String credential = ControlCredentialFile.read(dataDirectory, credentialName); HttpControlTransport transport = new HttpControlTransport(settings.endpoint(), credential, settings.connectTimeoutMillis(), settings.requestTimeoutMillis()); @@ -210,12 +251,31 @@ public static ControlConnector create(VotingPluginProxy proxy) throws IOExceptio backends.sort(Comparator.comparing(ObservedBackend::backendId)); return List.copyOf(backends); }; + HostConfiguration hosted = new HostConfiguration(config.getControlHostedEnabled(), + config.getControlHostedAutoDownload(), config.getControlHostedAutoUpdate(), + config.getControlHostedDownloadUrl(), config.getControlHostedSha256(), + config.getControlHostedJarFile(), config.getControlHostedDataDirectory(), + config.getControlHostedHost(), config.getControlHostedPort(), + config.getControlHostedStartupTimeoutSeconds(), config.getControlHostedDownloadTimeoutSeconds()); + boolean directLocalDeploymentEndpoint = HostedControlManager.isDirectLocalEndpoint( + settings.endpoint().toString(), hosted); + boolean deploymentEndpointAllowed = PluginDeploymentService.credentialEndpointAllowed( + settings.endpoint(), directLocalDeploymentEndpoint); + PluginDeploymentService deployments = deploymentRouteCurrent && deploymentEndpointAllowed + ? prepareDeployment(proxy) : null; + if (deploymentRouteCurrent && !deploymentEndpointAllowed) { + proxy.log("[Control] Plugin deployment staging requires HTTPS unless Control is hosted directly on this node"); + } + HttpClient deploymentHttp = deployments == null ? null : HttpClient.newBuilder() + .connectTimeout(Duration.ofMillis(settings.connectTimeoutMillis())) + .followRedirects(HttpClient.Redirect.NEVER).build(); ControlConnector connector = new ControlConnector(settings, proxy.getScheduler(), transport, snapshot, message -> proxy.log("[Control] " + message), UUID.randomUUID(), () -> ThreadLocalRandom.current().nextLong(), new ProxyRoutingConfigurationService(proxy), dataDirectory, route, recovering, proxy::restartControlServicesAfterRecovery, server -> proxy.testBackendCommunication(server, 5000L), new ProxyMethodConfigurationService(proxy), - () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy)); + () -> proxy.reloadCore(true), new ProxyConfigurationFileService(proxy), + deployments, deploymentHttp, credential, directLocalDeploymentEndpoint); if (recovered != null) connector.completedTasks.putAll(recovered.results()); return connector; } @@ -228,6 +288,116 @@ public void start() { schedule(0); operationPolling = scheduler.scheduleWithFixedDelay(this::pollOperations, OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + if (deployments != null) { + deploymentPolling = scheduler.scheduleWithFixedDelay(this::pollDeployments, + OPERATION_POLL_MILLIS, OPERATION_POLL_MILLIS, TimeUnit.MILLISECONDS); + } + } + + /** Keeps large artifact transfer and disk staging off proxy event threads and serializes it with operations. */ + private void pollDeployments() { + CompletableFuture done = new CompletableFuture<>(); + synchronized (operationLifecycle) { + if (closed || !registered || status != Status.CONNECTED || !deploymentsAccepted || deployments == null + || !inFlight.compareAndSet(false, true)) return; + activeOperation = done; + } + CompletableFuture work; + try { + CompletableFuture claim = transport.send(deploymentClaimRequest()); + activeRequest = claim; + work = claim.thenCompose(this::handleDeploymentClaim); + } catch (RuntimeException failure) { + work = CompletableFuture.failedFuture(failure); + } + work.whenComplete((ignored, failure) -> { + Throwable cause = failure == null ? null : unwrap(failure); + activeRequest = null; + if (cause == null) { + done.complete(null); + } else { + registered = false; + done.completeExceptionally(cause); + } + if (activeOperation == done) activeOperation = null; + finishCycle(); + if (cause != null && !closed) { + ScheduledFuture heartbeat = scheduled; + if (heartbeat != null) heartbeat.cancel(false); + onFailure(cause); + } + }); + } + + private Request deploymentClaimRequest() { + JsonObject body = sessionBody(); + return new Request("POST", "/api/v1/nodes/" + settings.nodeId() + "/deployments", body.toString()); + } + + private CompletableFuture handleDeploymentClaim(Response response) { + if (response.statusCode == 204 || closed) return CompletableFuture.completedFuture(null); + if (response.statusCode == 404) { + registered = false; + throw new RegistryLostException(); + } + requireSuccess(response); + PluginDeploymentService.Task task = deploymentTask(parseObject(response.body)); + final CompletableFuture deploymentWork; + synchronized (operationLifecycle) { + if (closed) return CompletableFuture.completedFuture(null); + deploymentWork = CompletableFuture.supplyAsync(() -> deployments.deploy(task, settings.endpoint(), + directLocalDeploymentEndpoint, settings.nodeId(), sessionId, deploymentCredential, deploymentHttp, + Duration.ofMillis(settings.requestTimeoutMillis()), () -> !closed), deploymentExecutor); + activeDeploymentWork = deploymentWork; + } + return deploymentWork.thenCompose(result -> { + if (closed) return CompletableFuture.completedFuture(null); + JsonObject body = new JsonObject(); + body.addProperty("sessionId", sessionId.toString()); + body.addProperty("success", result.success()); + body.addProperty("code", result.code()); + body.addProperty("message", boundedResultMessage(result.message())); + body.addProperty("attemptId", task.attemptId().toString()); + CompletableFuture submitted = transport.send(new Request("POST", "/api/v1/nodes/" + + settings.nodeId() + "/deployments/" + task.deploymentId() + "/result", body.toString())); + activeRequest = submitted; + return submitted.thenAccept(ControlConnector::requireSuccess); + }).whenComplete((ignored, failure) -> { + synchronized (operationLifecycle) { + if (activeDeploymentWork == deploymentWork) activeDeploymentWork = null; + } + }); + } + + static PluginDeploymentService.Task deploymentTask(JsonObject task) { + try { + if (task == null || task.size() != DEPLOYMENT_TASK_FIELDS.size() + || !DEPLOYMENT_TASK_FIELDS.containsAll(task.keySet())) throw new IllegalArgumentException(); + return new PluginDeploymentService.Task(UUID.fromString(requireString(task, "deploymentId")), + requireString(task, "artifactId"), requireString(task, "sha256"), + Long.parseLong(requireString(task, "size")), UUID.fromString(requireString(task, "attemptId"))); + } catch (RuntimeException failure) { + throw new MalformedResponseException(); + } + } + + private static PluginDeploymentService prepareDeployment(VotingPluginProxy proxy) { + if (!proxyDeploymentSupported(System.getProperty("os.name", ""))) { + proxy.log("[Control] Plugin deployment staging is unavailable on Windows proxies; capability not advertised"); + return null; + } + try { + var source = proxy.getClass().getProtectionDomain().getCodeSource(); + if (source == null || !"file".equalsIgnoreCase(source.getLocation().getProtocol())) return null; + return PluginDeploymentService.proxy(Path.of(source.getLocation().toURI())); + } catch (IOException | URISyntaxException | RuntimeException failure) { + proxy.log("[Control] Plugin deployment staging is unavailable; capability not advertised"); + return null; + } + } + + static boolean proxyDeploymentSupported(String osName) { + return !DurableFiles.isWindowsName(osName); } /** Polls only the operation queue; heartbeat and presence retain their configured cadence. */ @@ -434,7 +604,10 @@ private void handlePrimaryResponse(Response response, boolean registration) { } acceptedCapabilities = Set.copyOf(negotiated); configurationAccepted = acceptedCapabilities.stream().anyMatch(Set.of(CONFIGURATION_CAPABILITY, - COMMUNICATION_TEST_CAPABILITY, PROXY_METHOD_CAPABILITY, PROXY_FILE_CAPABILITY)::contains); + COMMUNICATION_TEST_CAPABILITY, PROXY_METHOD_CAPABILITY, PROXY_METHOD_HTTP_CAPABILITY, + PROXY_FILE_CAPABILITY)::contains); + deploymentsAccepted = deployments != null + && acceptedCapabilities.contains(PluginDeploymentService.CAPABILITY); } } @@ -811,7 +984,7 @@ private CompletableFuture executeTask(UUID operationId, JsonObject t if (isCommunicationTest(requested)) return acceptedCapabilities.contains(COMMUNICATION_TEST_CAPABILITY) ? executeCommunicationTest(task, requested) : completed(TaskResult.failure("UNSUPPORTED", "Communication testing was not negotiated")); - if (isProxyMethod(requested)) return acceptedCapabilities.contains(PROXY_METHOD_CAPABILITY) + if (isProxyMethod(requested)) return acceptedCapabilities.contains(proxyMethodCapability(requested)) ? executeProxyMethod(operationId, task, requested) : completed(TaskResult.failure("UNSUPPORTED", "Proxy method control was not negotiated")); if (!acceptedCapabilities.contains(CONFIGURATION_CAPABILITY)) { @@ -998,10 +1171,17 @@ private boolean capabilityAccepted(StoredResult result) { private static String requiredCapability(JsonObject configuration) { if (isProxyFile(configuration)) return PROXY_FILE_CAPABILITY; if (isCommunicationTest(configuration)) return COMMUNICATION_TEST_CAPABILITY; - if (isProxyMethod(configuration)) return PROXY_METHOD_CAPABILITY; + if (isProxyMethod(configuration)) return proxyMethodCapability(configuration); return CONFIGURATION_CAPABILITY; } + private static String proxyMethodCapability(JsonObject configuration) { + JsonObject options = configuration == null ? null : configuration.getAsJsonObject("options"); + return options != null && options.has("method") + && "HTTP".equalsIgnoreCase(options.get("method").getAsString().trim()) + ? PROXY_METHOD_HTTP_CAPABILITY : PROXY_METHOD_CAPABILITY; + } + private static CompletableFuture completed(TaskResult result) { return CompletableFuture.completedFuture(result); } @@ -1088,12 +1268,22 @@ static List boundedResultChanges(List changes) { } private void addCapabilities(JsonObject body) { + addCapabilities(body, configurationService != null, communicationTest != null, methodConfigurationService != null, + fileConfigurationService != null, deployments != null); + } + + static void addCapabilities(JsonObject body, boolean configurationReady, boolean communicationReady, + boolean methodReady, boolean fileReady, boolean deploymentReady) { JsonArray advertised = new JsonArray(); BASE_CAPABILITIES.stream().sorted().forEach(advertised::add); - if (configurationService != null) advertised.add(CONFIGURATION_CAPABILITY); - if (communicationTest != null) advertised.add(COMMUNICATION_TEST_CAPABILITY); - if (methodConfigurationService != null) advertised.add(PROXY_METHOD_CAPABILITY); - if (fileConfigurationService != null) advertised.add(PROXY_FILE_CAPABILITY); + if (configurationReady) advertised.add(CONFIGURATION_CAPABILITY); + if (communicationReady) advertised.add(COMMUNICATION_TEST_CAPABILITY); + if (methodReady) { + advertised.add(PROXY_METHOD_CAPABILITY); + advertised.add(PROXY_METHOD_HTTP_CAPABILITY); + } + if (fileReady) advertised.add(PROXY_FILE_CAPABILITY); + if (deploymentReady) advertised.add(PluginDeploymentService.CAPABILITY); body.add("capabilities", advertised); JsonArray required = new JsonArray(); required.add("presence.snapshot"); @@ -1133,6 +1323,12 @@ public void close() { } ScheduledFuture polling = operationPolling; if (polling != null) polling.cancel(false); + ScheduledFuture deployment = deploymentPolling; + if (deployment != null) deployment.cancel(false); + if (deployments != null) deployments.cancel(); + CompletableFuture deploymentWork = activeDeploymentWork; + if (deploymentWork != null) deploymentWork.cancel(true); + if (deploymentExecutor != null) deploymentExecutor.shutdownNow(); CompletableFuture request = activeRequest; if (request != null) { request.cancel(true); @@ -1150,6 +1346,16 @@ public void close() { throw new IllegalStateException("Interrupted while waiting for the Control operation", e); } } + if (deploymentExecutor != null) { + try { + if (!deploymentExecutor.awaitTermination(OPERATION_SHUTDOWN_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + throw new IllegalStateException("Control deployment worker did not stop before connector shutdown"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for the Control deployment worker", e); + } + } transport.close(); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java index c98f054c8..4d04c9f84 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationService.java @@ -58,6 +58,22 @@ private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig } } break; + case HTTP: + if (blank(config.getHttpHost()) || config.getHttpPort() < 1 || config.getHttpPort() > 65535) { + throw new IllegalArgumentException("HTTP.Host and HTTP.Port must be set"); + } + String endpoint = config.getHttpPublicEndpoint(); + if (blank(endpoint)) throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); + URI httpEndpoint; + try { httpEndpoint = URI.create(endpoint); } + catch (RuntimeException invalid) { throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); } + if (!"https".equalsIgnoreCase(httpEndpoint.getScheme()) || httpEndpoint.getHost() == null + || httpEndpoint.getPort() == 0 || httpEndpoint.getPort() > 65535 + || httpEndpoint.getUserInfo() != null || httpEndpoint.getQuery() != null || httpEndpoint.getFragment() != null + || (httpEndpoint.getPath() != null && !httpEndpoint.getPath().isEmpty() && !"/".equals(httpEndpoint.getPath()))) { + throw new IllegalArgumentException("HTTP.PublicEndpoint must be an HTTPS origin"); + } + break; case MYSQL: if (!config.hasDatabaseConfigured()) { throw new IllegalArgumentException("The proxy database Host must be configured for MYSQL"); @@ -70,19 +86,36 @@ private void validate(ProxyMethodConfiguration proposal, VotingPluginProxyConfig public void apply(ProxyMethodConfiguration proposal, String expectedRevision) throws IOException { if (expectedRevision == null) throw new StaleRevisionException(); + boolean[] preparedHttp = { false }; try { proxy.getConfig().persistControlProxyMethod(proposal.method().name(), expectedRevision, - latest -> validate(proposal, latest)); + latest -> { + validate(proposal, latest); + if (proposal.method() == BungeeMethod.HTTP + && !proxy.hasMatchingLiveHttpTransport(latest)) { + if (proxy.hasLiveHttpBind(latest)) { + throw new IllegalArgumentException( + "HTTP.PublicEndpoint changed while the HTTP listener is bound; restart is required before applying it"); + } + proxy.prepareHttpTransportChange(latest); + preparedHttp[0] = true; + } + }); proxy.getConfig().verifyControlProxyRoutingInstalled(); } catch (VotingPluginProxyConfig.StaleControlRevisionException e) { + if (preparedHttp[0]) proxy.cancelPreparedHttpTransportChange(); throw new StaleRevisionException(); } catch (IllegalArgumentException validation) { + if (preparedHttp[0]) proxy.cancelPreparedHttpTransportChange(); throw validation; } catch (DurableFiles.PublishedException published) { + if (preparedHttp[0]) proxy.cancelPreparedHttpTransportChange(); throw rollbackAfterFailure(published); } catch (IOException failure) { + if (preparedHttp[0]) proxy.cancelPreparedHttpTransportChange(); throw failure; } catch (RuntimeException failure) { + if (preparedHttp[0]) proxy.cancelPreparedHttpTransportChange(); throw rollbackAfterFailure(failure); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyCapabilityStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyCapabilityStore.java new file mode 100644 index 000000000..70b3808f9 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/multiproxy/MultiProxyCapabilityStore.java @@ -0,0 +1,171 @@ +package com.bencodez.votingplugin.proxy.multiproxy; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +import com.bencodez.votingplugin.util.DurableFiles; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +/** + * Stores only the stable identities of peers that have completed the + * durable-delivery capability handshake and the bounded discovery deadlines + * for peers which have not advertised yet. A lease is deliberately not stored: + * every process must renew a peer's current handshake before publishing a + * durable outbox retry. + */ +final class MultiProxyCapabilityStore { + static final String FILE_NAME = ".multiproxy-capability-peers.json"; + private static final int VERSION = 2; + private static final int LEGACY_VERSION = 1; + private static final int MAX_BYTES = 64 * 1024; + private static final int MAX_PEERS = 256; + private static final int MAX_PEER_LENGTH = 256; + + private MultiProxyCapabilityStore() { } + + static State load(Path dataDirectory) throws IOException { + Path target = target(dataDirectory); + if (!Files.exists(target, LinkOption.NOFOLLOW_LINKS)) return State.empty(); + if (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) + || Files.size(target) > MAX_BYTES) throw new IOException("Multi-proxy capability state is unsafe"); + byte[] bytes; + try (SeekableByteChannel channel = Files.newByteChannel(target, + Set.of(StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS))) { + ByteBuffer buffer = ByteBuffer.allocate((int) Files.size(target) + 1); + while (channel.read(buffer) >= 0 && buffer.hasRemaining()) { } + if (!buffer.hasRemaining()) throw new IOException("Multi-proxy capability state is too large"); + buffer.flip(); + bytes = new byte[buffer.remaining()]; + buffer.get(bytes); + } + try { + JsonElement parsed = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)); + if (!parsed.isJsonObject()) throw invalid(); + JsonObject root = parsed.getAsJsonObject(); + if (!root.has("version") || (root.get("version").getAsInt() != VERSION + && root.get("version").getAsInt() != LEGACY_VERSION) + || !root.has("peers") || !root.get("peers").isJsonArray()) throw invalid(); + JsonArray peers = root.getAsJsonArray("peers"); + if (peers.size() > MAX_PEERS) throw invalid(); + Set restored = new LinkedHashSet<>(); + for (JsonElement peer : peers) { + if (!peer.isJsonPrimitive() || !peer.getAsJsonPrimitive().isString()) throw invalid(); + String name = peer.getAsString(); + if (!validPeer(name) || !restored.add(name)) throw invalid(); + } + Map discoveryDeadlines = new LinkedHashMap<>(); + if (root.has("discoveryDeadlines")) { + if (!root.get("discoveryDeadlines").isJsonObject()) throw invalid(); + JsonObject storedDeadlines = root.getAsJsonObject("discoveryDeadlines"); + if (storedDeadlines.size() > MAX_PEERS) throw invalid(); + for (Map.Entry entry : storedDeadlines.entrySet()) { + if (!validPeer(entry.getKey()) || !entry.getValue().isJsonPrimitive() + || !entry.getValue().getAsJsonPrimitive().isNumber()) throw invalid(); + long deadline = entry.getValue().getAsLong(); + if (deadline <= 0L || discoveryDeadlines.put(entry.getKey(), deadline) != null) throw invalid(); + } + } + long lastObservedMillis = 0L; + if (root.has("lastObservedMillis")) { + if (!root.get("lastObservedMillis").isJsonPrimitive() + || !root.get("lastObservedMillis").getAsJsonPrimitive().isNumber()) throw invalid(); + lastObservedMillis = root.get("lastObservedMillis").getAsLong(); + if (lastObservedMillis < 0L) throw invalid(); + } + return new State(restored, discoveryDeadlines, lastObservedMillis); + } catch (RuntimeException malformed) { + throw new IOException("Multi-proxy capability state is malformed", malformed); + } + } + + static void save(Path dataDirectory, Collection peers) throws IOException { + save(dataDirectory, peers, Map.of()); + } + + static void save(Path dataDirectory, Collection peers, Map discoveryDeadlines) + throws IOException { + save(dataDirectory, peers, discoveryDeadlines, 0L); + } + + static void save(Path dataDirectory, Collection peers, Map discoveryDeadlines, + long lastObservedMillis) throws IOException { + if (dataDirectory == null || peers == null || discoveryDeadlines == null || peers.size() > MAX_PEERS + || discoveryDeadlines.size() > MAX_PEERS || lastObservedMillis < 0L) + throw new IOException("Invalid capability state"); + Set copy = new LinkedHashSet<>(); + for (String peer : peers) { + if (!validPeer(peer) || !copy.add(peer)) throw new IOException("Invalid capability peer"); + } + JsonObject root = new JsonObject(); + root.addProperty("version", VERSION); + JsonArray listed = new JsonArray(); + copy.stream().sorted().forEach(listed::add); + root.add("peers", listed); + JsonObject storedDeadlines = new JsonObject(); + for (String peer : discoveryDeadlines.keySet().stream().sorted().toList()) { + Long deadline = discoveryDeadlines.get(peer); + if (!validPeer(peer) || deadline == null || deadline <= 0L) { + throw new IOException("Invalid capability discovery state"); + } + storedDeadlines.addProperty(peer, deadline); + } + root.add("discoveryDeadlines", storedDeadlines); + root.addProperty("lastObservedMillis", lastObservedMillis); + byte[] serialized = root.toString().getBytes(StandardCharsets.UTF_8); + if (serialized.length > MAX_BYTES) throw new IOException("Multi-proxy capability state is too large"); + + Files.createDirectories(dataDirectory); + Path target = target(dataDirectory); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS) + && (Files.isSymbolicLink(target) || !Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS))) { + throw new IOException("Multi-proxy capability state is unsafe"); + } + Path staging = Files.createTempFile(dataDirectory, ".multiproxy-capabilities-", ".json"); + try { + Files.write(staging, serialized, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + DurableFiles.publishStagedFile(staging, target); + } finally { + Files.deleteIfExists(staging); + } + } + + private static Path target(Path dataDirectory) { + if (dataDirectory == null) throw new IllegalArgumentException("dataDirectory"); + return dataDirectory.resolve(FILE_NAME); + } + + private static boolean validPeer(String peer) { + return peer != null && !peer.isBlank() && peer.length() <= MAX_PEER_LENGTH + && peer.equals(peer.toLowerCase(Locale.ROOT)); + } + + private static IllegalArgumentException invalid() { + return new IllegalArgumentException("Invalid capability state"); + } + + record State(Set peers, Map discoveryDeadlines, long lastObservedMillis) { + State { + peers = Set.copyOf(peers); + discoveryDeadlines = Map.copyOf(discoveryDeadlines); + } + + static State empty() { + return new State(Set.of(), Map.of(), 0L); + } + } +} 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 be793efa3..bb4cb9ad8 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 @@ -2,10 +2,18 @@ package com.bencodez.votingplugin.proxy.multiproxy; import java.io.File; +import java.io.IOException; +import java.nio.file.Path; import java.util.Collection; +import java.util.HashSet; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; +import java.util.Set; +import java.util.UUID; import com.bencodez.simpleapi.encryption.EncryptionHandler; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; @@ -25,6 +33,46 @@ public abstract class MultiProxyHandler { private HashMap multiproxyClientHandles; private SocketHandler multiproxySocketHandler; + /** A renewable lease prevents a restarted/rolled-back peer staying ACK-capable forever. */ + static final long VOTE_CAPABILITY_LEASE_MILLIS = 5 * 60 * 1000L; + /** Bound retry-driven capability advertisements while a durable outbox is waiting. */ + static final long VOTE_CAPABILITY_RENEWAL_MIN_INTERVAL_MILLIS = 30 * 1000L; + /** + * A discovery reply can arrive asynchronously (notably through Redis). Renew + * before the bounded discovery window closes, leaving the final announcement a + * real interval to be observed instead of classifying a peer as legacy in the + * same retry that sent its last handshake. + */ + static final long VOTE_CAPABILITY_DISCOVERY_RENEWAL_MIN_INTERVAL_MILLIS = 10 * 1000L; + /** + * A newly configured peer gets this one bounded opportunity to answer the + * capability handshake before it is treated as a legacy one-way peer. The + * deadline is durable so a restart cannot extend the window indefinitely. + */ + static final long VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS = 30 * 1000L; + private final Map acknowledgedVoteCapabilityPeers = new HashMap<>(); + /** + * Peers which have previously completed the durable-delivery handshake. Retain + * this separately from the renewable lease: once a known-capable peer drops + * offline, sending it a legacy vote would acknowledge a fire-and-forget publish + * as success and lose the sender's durable completion fence. + */ + private final Set knownVoteCapabilityPeers = new HashSet<>(); + /** Durable, per-peer deadlines for the initial capability discovery window. */ + private final Map voteCapabilityDiscoveryDeadlines = new HashMap<>(); + /** + * We cannot safely classify configured peers after the durable state is corrupt + * or cannot be updated. Do not create a new mixed-version outbox in that + * state: an old peer can never acknowledge it, while demoting a formerly + * capable peer would lose its sender fence. Recovery is deliberately an + * operator action (repair/remove the named state file, then restart). + */ + private boolean voteCapabilityRecoveryBlocked; + private long lastVoteCapabilityAdvertisementMillis = Long.MIN_VALUE; + /** A newly persisted discovery deadline must cause an initial handshake promptly. */ + private boolean voteCapabilityDiscoveryAnnouncementRequired; + /** Last wall-clock observation durably associated with discovery deadlines. */ + private long lastVoteCapabilityObservationMillis; @Getter private RedisHandler multiProxyRedis; @@ -35,6 +83,10 @@ public abstract class MultiProxyHandler { public MultiProxyHandler() { } + long capabilityNowMillis() { + return System.currentTimeMillis(); + } + /** * Adds a non-voted player to the cache. * @@ -64,6 +116,12 @@ public synchronized void close() { multiProxyRedis = null; stopSocketClients(multiproxyClientHandles); multiproxyClientHandles = null; + acknowledgedVoteCapabilityPeers.clear(); + knownVoteCapabilityPeers.clear(); + voteCapabilityDiscoveryDeadlines.clear(); + voteCapabilityRecoveryBlocked = false; + lastVoteCapabilityAdvertisementMillis = Long.MIN_VALUE; + lastVoteCapabilityObservationMillis = 0L; } /** @@ -249,10 +307,333 @@ public synchronized void close() { public abstract void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, VoteTotalsSnapshot text, String uuid); + /** + * Triggers a vote using the stable ID carried by a multi-proxy envelope. + * Existing implementations retain the legacy callback contract; implementations + * that support durable retries should override this overload. + */ + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId) { + triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid); + } + + /** + * Additive reliable-delivery overload. Existing integrations retain the + * stable-ID behavior without needing to understand acknowledgements. + */ + public void triggerVote(String player, String service, boolean realVote, boolean timeQueue, long queueTime, + VoteTotalsSnapshot text, String uuid, UUID voteId, String origin) { + triggerVote(player, service, realVote, timeQueue, queueTime, text, uuid, voteId); + } + + /** Called when a receiver acknowledges this proxy's stable vote ID. */ + public void onMultiProxyVoteAcknowledged(UUID voteId, String recipient) { + // Optional for legacy implementations. + } + + /** Called on the origin when a receiver confirms fence retirement. */ + public void onMultiProxyVoteRetirementAcknowledged(UUID voteId, String recipient) { + // Optional for legacy implementations. + } + + /** Called on a receiver after a targeted retirement request is authenticated. */ + public void onMultiProxyVoteRetirementRequested(UUID voteId, String origin) { + // Optional for legacy implementations. + } + + /** + * Publishes an acknowledgement after receiver completion is durable. The + * broadcast route keeps sockets and Redis compatible; recipients filter it by + * the origin field, and duplicate votes cause the receiver to acknowledge again. + */ + public void acknowledgeMultiProxyVote(UUID voteId, String origin) { + if (voteId == null || origin == null || origin.isBlank()) return; + sendMultiProxyEnvelopeAccepted(VotingPluginWire.multiProxyVoteAck(voteId, origin, getMultiProxyServerName())); + } + + public void requestMultiProxyVoteRetirement(UUID voteId, String origin, String recipient) { + if (voteId == null || origin == null || origin.isBlank() || recipient == null || recipient.isBlank()) return; + sendMultiProxyEnvelopeAccepted(VotingPluginWire.multiProxyVoteRetire(voteId, origin, recipient), + java.util.Set.of(recipient)); + } + + public void acknowledgeMultiProxyVoteRetirement(UUID voteId, String origin) { + if (voteId == null || origin == null || origin.isBlank()) return; + sendMultiProxyEnvelopeAccepted( + VotingPluginWire.multiProxyVoteRetireAck(voteId, origin, getMultiProxyServerName())); + } + + /** Returns every configured remote proxy recipient, independent of version. */ + public synchronized Set getConfiguredMultiProxyVoteRecipients() { + return new LinkedHashSet<>(configuredMultiProxyRecipientNames().values()); + } + + private synchronized Map configuredMultiProxyRecipientNames() { + Collection source = MultiProxyMethod.SOCKETS.equals(getMultiProxyMethod()) + ? getMultiProxyServers() : getProxyServers(); + Map recipients = new LinkedHashMap<>(); + if (source == null) return recipients; + for (String server : source) { + if (server != null && !server.isBlank()) + recipients.putIfAbsent(server.toLowerCase(Locale.ROOT), server); + } + return recipients; + } + + /** Returns only configured peers that explicitly support durable acknowledgements. */ + public synchronized Set getMultiProxyVoteRecipients() { + long now = capabilityNowMillis(); + acknowledgedVoteCapabilityPeers.entrySet().removeIf(entry -> entry.getValue() <= now); + Set recipients = new LinkedHashSet<>(); + for (Map.Entry configured : configuredMultiProxyRecipientNames().entrySet()) { + if (acknowledgedVoteCapabilityPeers.containsKey(configured.getKey())) recipients.add(configured.getValue()); + } + return recipients; + } + + /** + * Returns configured peers which previously advertised durable acknowledgements + * but whose renewable lease has expired. A sender must retain these recipients + * in its durable outbox and wait for the capability handshake to renew instead + * of demoting them to the legacy one-way route. + */ + public synchronized Set getMultiProxyVoteRecipientsAwaitingCapabilityRenewal() { + long now = capabilityNowMillis(); + acknowledgedVoteCapabilityPeers.entrySet().removeIf(entry -> entry.getValue() <= now); + Set recipients = new LinkedHashSet<>(); + for (Map.Entry configured : configuredMultiProxyRecipientNames().entrySet()) { + if (knownVoteCapabilityPeers.contains(configured.getKey()) + && !acknowledgedVoteCapabilityPeers.containsKey(configured.getKey())) { + recipients.add(configured.getValue()); + } + } + return recipients; + } + + /** + * Returns never-observed configured peers whose initial capability handshake + * is still within its bounded discovery window. Callers retain the vote for a + * retry while this set is non-empty, rather than publishing a one-way legacy + * copy before a newly upgraded peer can identify itself. + * + *

The deadline is recorded before it is exposed. If recording it fails, we + * block forwarding rather than allowing repeated restarts to make this window + * unbounded or silently demoting a peer to legacy.

+ */ + public synchronized Set getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery() { + if (voteCapabilityRecoveryBlocked) return Set.of(); + long now = capabilityNowMillis(); + Map configured = configuredMultiProxyRecipientNames(); + Map updated = new HashMap<>(voteCapabilityDiscoveryDeadlines); + boolean clockRolledBack = lastVoteCapabilityObservationMillis > 0L && now < lastVoteCapabilityObservationMillis; + // A wall clock moving backwards must never grant a second discovery window. + // Expire the already-recorded opportunity rather than waiting for the clock + // to catch up (which can otherwise retain a vote indefinitely after reboot). + if (clockRolledBack) { + updated.replaceAll((peer, deadline) -> deadline > now ? now : deadline); + } + boolean changed = updated.keySet().removeIf(peer -> !configured.containsKey(peer) + || knownVoteCapabilityPeers.contains(peer)); + changed |= clockRolledBack && !updated.equals(voteCapabilityDiscoveryDeadlines); + boolean addedDiscoveryDeadline = false; + for (String peer : configured.keySet()) { + if (knownVoteCapabilityPeers.contains(peer) || updated.containsKey(peer)) continue; + updated.put(peer, discoveryDeadline(now)); + changed = true; + addedDiscoveryDeadline = true; + } + long observed = Math.max(0L, now); + boolean activeDiscovery = false; + boolean discoveryExpiredSinceLastObservation = false; + for (Long deadline : updated.values()) { + if (deadline == null) continue; + if (deadline > now) { + activeDiscovery = true; + } else if (deadline > lastVoteCapabilityObservationMillis) { + // Preserve the first observation after expiry. Without it, a later + // clock rollback could make this already-consumed window live again. + discoveryExpiredSinceLastObservation = true; + } + } + // Keep a persisted high-water mark while discovery is live, but do so at + // the same bounded cadence as advertisements. Once an expired deadline is + // recorded, repeated legacy forwarding must not fsync this state per vote. + boolean observationChanged = !updated.isEmpty() && (clockRolledBack + || discoveryExpiredSinceLastObservation || (activeDiscovery + && (lastVoteCapabilityObservationMillis == 0L + || now - lastVoteCapabilityObservationMillis + >= VOTE_CAPABILITY_DISCOVERY_RENEWAL_MIN_INTERVAL_MILLIS))); + if ((changed || observationChanged) + && !saveVoteCapabilityState(knownVoteCapabilityPeers, updated, observed)) return Set.of(); + if (addedDiscoveryDeadline) voteCapabilityDiscoveryAnnouncementRequired = true; + Set recipients = new LinkedHashSet<>(); + for (Map.Entry configuredPeer : configured.entrySet()) { + Long deadline = voteCapabilityDiscoveryDeadlines.get(configuredPeer.getKey()); + if (!knownVoteCapabilityPeers.contains(configuredPeer.getKey()) && deadline != null && deadline > now) { + recipients.add(configuredPeer.getValue()); + } + } + return recipients; + } + + /** Broadcasts this node's durable-ACK capability to configured peers. */ + public synchronized void announceMultiProxyVoteCapability() { + lastVoteCapabilityAdvertisementMillis = capabilityNowMillis(); + voteCapabilityDiscoveryAnnouncementRequired = false; + sendMultiProxyEnvelopeAccepted(VotingPluginWire.multiProxyCapabilities(getMultiProxyServerName(), 1)); + } + + /** + * Re-advertises durable delivery support for an outbox that is waiting for an + * expired peer lease. This is deliberately rate limited: the queue retries + * much more frequently than the handshake needs to be broadcast. + * + * @return whether an advertisement was attempted + */ + public synchronized boolean renewMultiProxyVoteCapabilityIfDue() { + return renewMultiProxyVoteCapabilityIfDue(VOTE_CAPABILITY_RENEWAL_MIN_INTERVAL_MILLIS); + } + + /** + * Re-advertises while an initial discovery deadline is still live. The shorter + * cadence guarantees a settling interval before the durable deadline expires; + * callers must not invoke this after the discovery query says the window ended. + */ + public synchronized boolean renewMultiProxyVoteCapabilityDiscoveryIfDue() { + return renewMultiProxyVoteCapabilityIfDue(VOTE_CAPABILITY_DISCOVERY_RENEWAL_MIN_INTERVAL_MILLIS); + } + + private boolean renewMultiProxyVoteCapabilityIfDue(long minimumIntervalMillis) { + long now = capabilityNowMillis(); + if (!voteCapabilityDiscoveryAnnouncementRequired && lastVoteCapabilityAdvertisementMillis != Long.MIN_VALUE + && now >= lastVoteCapabilityAdvertisementMillis + && now - lastVoteCapabilityAdvertisementMillis < minimumIntervalMillis) { + return false; + } + lastVoteCapabilityAdvertisementMillis = now; + voteCapabilityDiscoveryAnnouncementRequired = false; + sendMultiProxyEnvelopeAccepted(VotingPluginWire.multiProxyCapabilities(getMultiProxyServerName(), 1)); + return true; + } + + /** + * Whether durable peer classification needs an explicit on-disk recovery. + * Callers must not start a new multi-proxy forwarding attempt while this is + * true, because a legacy peer cannot complete an acknowledgement outbox. + */ + public synchronized boolean isMultiProxyVoteCapabilityRecoveryBlocked() { + return voteCapabilityRecoveryBlocked; + } + + /** Restores durable peer identities without restoring any expired lease. */ + synchronized void restoreVoteCapabilityPeers() { + acknowledgedVoteCapabilityPeers.clear(); + knownVoteCapabilityPeers.clear(); + voteCapabilityDiscoveryDeadlines.clear(); + voteCapabilityRecoveryBlocked = false; + lastVoteCapabilityObservationMillis = 0L; + Path dataDirectory = capabilityStateDirectory(); + // Third-party MultiProxyHandler integrations predate durable state and may + // intentionally provide no plugin folder. Preserve their established + // in-memory handshake behavior; built-in proxy handlers always provide one. + if (dataDirectory == null) return; + try { + MultiProxyCapabilityStore.State restored = MultiProxyCapabilityStore.load(dataDirectory); + Set configured = configuredMultiProxyRecipientNames().keySet(); + Set restoredPeers = new HashSet<>(); + for (String peer : restored.peers()) if (configured.contains(peer)) restoredPeers.add(peer); + Map restoredDeadlines = new HashMap<>(); + for (Map.Entry entry : restored.discoveryDeadlines().entrySet()) { + if (configured.contains(entry.getKey()) && !restoredPeers.contains(entry.getKey())) { + restoredDeadlines.put(entry.getKey(), entry.getValue()); + } + } + long now = capabilityNowMillis(); + boolean clockRolledBack = restored.lastObservedMillis() > 0L && now < restored.lastObservedMillis(); + if (clockRolledBack) { + restoredDeadlines.replaceAll((peer, deadline) -> deadline > now ? now : deadline); + } + long observed = Math.max(0L, clockRolledBack ? now : Math.max(now, restored.lastObservedMillis())); + boolean pruned = !restoredPeers.equals(restored.peers()) + || !restoredDeadlines.equals(restored.discoveryDeadlines()); + boolean observationChanged = !restoredDeadlines.isEmpty() && observed != restored.lastObservedMillis(); + // Persist removal before installing the filtered identities. Otherwise a + // later re-add of the same name could resurrect an ACK-capable identity. + if ((pruned || observationChanged) + && !saveVoteCapabilityState(restoredPeers, restoredDeadlines, observed)) return; + knownVoteCapabilityPeers.addAll(restoredPeers); + voteCapabilityDiscoveryDeadlines.putAll(restoredDeadlines); + lastVoteCapabilityObservationMillis = observed; + } catch (IOException | IllegalArgumentException stateFailure) { + blockVoteCapabilityRecovery(); + } + } + + /** + * Makes a peer eligible for durable publishes only after its durable identity is + * recorded. A failed write never silently converts an acknowledged-capable + * peer to the legacy route during this process. + */ + private boolean acceptVoteCapabilityPeer(String normalized) { + if (voteCapabilityRecoveryBlocked) return false; + boolean newPeer = !knownVoteCapabilityPeers.contains(normalized); + if (!newPeer) return true; + Set updated = new HashSet<>(knownVoteCapabilityPeers); + updated.add(normalized); + Map updatedDeadlines = new HashMap<>(voteCapabilityDiscoveryDeadlines); + updatedDeadlines.remove(normalized); + boolean accepted = saveVoteCapabilityState(updated, updatedDeadlines, lastVoteCapabilityObservationMillis); + if (accepted) voteCapabilityDiscoveryAnnouncementRequired = false; + return accepted; + } + + private long discoveryDeadline(long now) { + return now > Long.MAX_VALUE - VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS ? Long.MAX_VALUE + : now + VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS; + } + + /** Saves and installs peer classification atomically from the sender's view. */ + private boolean saveVoteCapabilityState(Collection peers, Map discoveryDeadlines, + long lastObservedMillis) { + Path dataDirectory = capabilityStateDirectory(); + try { + if (dataDirectory != null) MultiProxyCapabilityStore.save(dataDirectory, peers, discoveryDeadlines, + lastObservedMillis); + knownVoteCapabilityPeers.clear(); + knownVoteCapabilityPeers.addAll(peers); + voteCapabilityDiscoveryDeadlines.clear(); + voteCapabilityDiscoveryDeadlines.putAll(discoveryDeadlines); + lastVoteCapabilityObservationMillis = lastObservedMillis; + return true; + } catch (IOException | IllegalArgumentException stateFailure) { + blockVoteCapabilityRecovery(); + return false; + } + } + + private void blockVoteCapabilityRecovery() { + voteCapabilityRecoveryBlocked = true; + logInfo("Multi-proxy forwarding is blocked: repair or remove " + MultiProxyCapabilityStore.FILE_NAME + + " and restart before forwarding votes"); + } + + private Path capabilityStateDirectory() { + File dataFolder = getPluginDataFolder(); + return dataFolder == null ? null : dataFolder.toPath(); + } + /** * Loads multi-proxy support. */ public synchronized void loadMultiProxySupport() { + acknowledgedVoteCapabilityPeers.clear(); + knownVoteCapabilityPeers.clear(); + voteCapabilityDiscoveryDeadlines.clear(); + voteCapabilityRecoveryBlocked = false; + lastVoteCapabilityAdvertisementMillis = Long.MIN_VALUE; + voteCapabilityDiscoveryAnnouncementRequired = false; + lastVoteCapabilityObservationMillis = 0L; + restoreVoteCapabilityPeers(); if (!getMultiProxySupportEnabled()) { return; } @@ -315,6 +696,7 @@ public void debug(String message) { } logInfo("Loaded multi-proxy support: " + getMultiProxyMethod().toString()); + announceMultiProxyVoteCapability(); } /** @@ -359,24 +741,77 @@ public void sendStatus() { * @param envelope the envelope to send */ public synchronized void sendMultiProxyEnvelope(JsonEnvelope envelope) { - if (envelope == null) { - return; + sendMultiProxyEnvelopeAccepted(envelope); + } + + /** + * Sends an envelope and reports whether the configured transport accepted it. + * + *

The legacy send method is intentionally retained for callers that do not + * need delivery fencing. A vote producer must use this result: a missing + * client, an unavailable Redis connection, an empty destination list, or a + * transport exception must leave the vote retryable instead of claiming that + * forwarding completed. Socket clients are fire-and-forget in SimpleAPI, so + * acceptance means that the configured client accepted the send invocation; + * the stable wire vote ID remains the receiver-side duplicate fence.

+ * + * @param envelope the envelope to send + * @return true only when every configured destination accepted the envelope + */ + public synchronized boolean sendMultiProxyEnvelopeAccepted(JsonEnvelope envelope) { + return sendMultiProxyEnvelopeAccepted(envelope, getConfiguredMultiProxyVoteRecipients()); + } + + /** + * Sends an envelope to a selected subset of configured peers. Reliable senders + * use this to retry only ACK-capable peers, avoiding duplicate legacy delivery. + */ + public synchronized boolean sendMultiProxyEnvelopeAccepted(JsonEnvelope envelope, Collection recipients) { + if (envelope == null) return false; + if (recipients == null || recipients.isEmpty()) return false; + Map requested = new LinkedHashMap<>(); + Map configuredNames = configuredMultiProxyRecipientNames(); + for (String recipient : recipients) { + if (recipient != null && !recipient.isBlank()) { + String normalized = recipient.toLowerCase(Locale.ROOT); + requested.putIfAbsent(normalized, configuredNames.getOrDefault(normalized, recipient)); + } } + if (requested.isEmpty()) return false; if (getMultiProxyMethod().equals(MultiProxyMethod.SOCKETS)) { - if (multiproxyClientHandles == null) { - return; - } - for (ClientHandler h : multiproxyClientHandles.values()) { - h.sendEnvelope(envelope); + if (multiproxyClientHandles == null || multiproxyClientHandles.isEmpty()) return false; + boolean accepted = true; + int destinations = 0; + for (Map.Entry entry : multiproxyClientHandles.entrySet()) { + if (entry.getKey() == null || !requested.containsKey(entry.getKey().toLowerCase(Locale.ROOT))) continue; + ClientHandler h = entry.getValue(); + if (h == null) { + accepted = false; + continue; + } + destinations++; + try { + h.sendEnvelope(envelope); + } catch (RuntimeException failure) { + accepted = false; + } } + return accepted && destinations == requested.size(); } else if (getMultiProxyMethod().equals(MultiProxyMethod.REDIS)) { - if (multiProxyRedis == null) { - return; - } - for (String server : getProxyServers()) { - multiProxyRedis.publishEnvelope("VotingPluginProxy_" + server, envelope); + if (multiProxyRedis == null) return false; + boolean accepted = true; + int destinations = 0; + for (String server : requested.values()) { + destinations++; + try { + multiProxyRedis.publishEnvelope("VotingPluginProxy_" + server, envelope); + } catch (RuntimeException failure) { + accepted = false; + } } + return accepted && destinations == requested.size(); } + return false; } static void stopSocketClients(Map clients) { @@ -439,15 +874,88 @@ private void handleEnvelope(JsonEnvelope envelope) { return; } + if (sub.equalsIgnoreCase(VotingPluginWire.SUB_MULTI_PROXY_VOTE_ACK)) { + String origin = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_ORIGIN, ""); + String recipient = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_RECIPIENT, ""); + if (!origin.equalsIgnoreCase(getMultiProxyServerName())) return; + try { + onMultiProxyVoteAcknowledged(UUID.fromString(f.getOrDefault(VotingPluginWire.K_VOTE_ID, "")), recipient); + } catch (IllegalArgumentException ignored) { + // Ignore malformed acknowledgements; they must never clear a sender fence. + } + return; + } + + if (sub.equalsIgnoreCase(VotingPluginWire.SUB_MULTI_PROXY_VOTE_RETIRE)) { + String origin = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_ORIGIN, ""); + String recipient = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_RECIPIENT, ""); + if (!recipient.equalsIgnoreCase(getMultiProxyServerName()) || origin.isBlank() + || !configuredMultiProxyRecipientNames().containsKey(origin.toLowerCase(Locale.ROOT))) return; + try { + onMultiProxyVoteRetirementRequested( + UUID.fromString(f.getOrDefault(VotingPluginWire.K_VOTE_ID, "")), origin); + } catch (IllegalArgumentException ignored) { } + return; + } + + if (sub.equalsIgnoreCase(VotingPluginWire.SUB_MULTI_PROXY_VOTE_RETIRE_ACK)) { + String origin = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_ORIGIN, ""); + String recipient = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_RECIPIENT, ""); + if (!origin.equalsIgnoreCase(getMultiProxyServerName())) return; + try { + onMultiProxyVoteRetirementAcknowledged( + UUID.fromString(f.getOrDefault(VotingPluginWire.K_VOTE_ID, "")), recipient); + } catch (IllegalArgumentException ignored) { } + return; + } + + if (sub.equalsIgnoreCase(VotingPluginWire.SUB_MULTI_PROXY_CAPABILITIES)) { + String recipient = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_RECIPIENT, ""); + boolean reply = Boolean.parseBoolean(f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_CAPABILITY_REPLY, "false")); + boolean replyRequired = false; + try { + int version = Integer.parseInt(f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_ACK_VERSION, "0")); + String normalized = recipient.toLowerCase(Locale.ROOT); + synchronized (this) { + if (version >= 1 && configuredMultiProxyRecipientNames().containsKey(normalized)) { + if (acceptVoteCapabilityPeer(normalized)) { + acknowledgedVoteCapabilityPeers.put(normalized, + capabilityNowMillis() + VOTE_CAPABILITY_LEASE_MILLIS); + } + replyRequired = !reply; + } + } + } catch (IllegalArgumentException ignored) { + // A malformed capability must leave the peer on the legacy route. + } + if (replyRequired) { + sendMultiProxyEnvelopeAccepted( + VotingPluginWire.multiProxyCapabilities(getMultiProxyServerName(), 1, true)); + } + return; + } + if (sub.equalsIgnoreCase(VotingPluginWire.SUB_VOTE) || sub.equalsIgnoreCase(VotingPluginWire.SUB_VOTE_ONLINE)) { + final VotingPluginWire.Vote wireVote = VotingPluginWire.readVote(envelope); final String player = f.getOrDefault(VotingPluginWire.K_PLAYER, ""); final String uuid = f.getOrDefault(VotingPluginWire.K_UUID, ""); final String service = f.getOrDefault(VotingPluginWire.K_SERVICE, ""); final String totals = f.getOrDefault(VotingPluginWire.K_TOTALS, ""); final boolean realVote = Boolean.parseBoolean(f.getOrDefault(VotingPluginWire.K_REAL_VOTE, "false")); + final String origin = f.getOrDefault(VotingPluginWire.K_MULTI_PROXY_ORIGIN, ""); + if (!origin.isBlank() && wireVote.voteId == null) { + // Reliable envelopes must never enter receiver processing without the + // stable identity used by its durable completion fence. + return; + } if (!player.isEmpty() && !uuid.isEmpty() && !service.isEmpty()) { - triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid); + if (origin.isBlank()) { + triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid); + } else { + triggerVote(player, service, realVote, true, 0L, VoteTotalsSnapshot.parseStorage(totals), uuid, + wireVote.voteId, origin); + } } return; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java index 4ea94a8a4..efcb36751 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityConfig.java @@ -367,6 +367,21 @@ public int getBungeePort() { return getInt(getNode("BungeeServer", "Port"), 1297); } + @Override + public String getHttpHost() { + return getString(getNode("HTTP", "Host"), "0.0.0.0"); + } + + @Override + public String getHttpPublicEndpoint() { + return getString(getNode("HTTP", "PublicEndpoint"), ""); + } + + @Override + public int getHttpPort() { + return getInt(getNode("HTTP", "Port"), 1297); + } + @Override public boolean getDebug() { return getBoolean(getNode("Debug"), false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityJsonVoteCache.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityJsonVoteCache.java index 0569de173..341153230 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityJsonVoteCache.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VelocityJsonVoteCache.java @@ -1,14 +1,25 @@ package com.bencodez.votingplugin.proxy.velocity; import java.io.File; +import java.nio.charset.StandardCharsets; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; import java.util.Collection; +import java.util.Locale; import com.bencodez.simpleapi.file.velocity.VelocityJSONFile; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.proxy.cache.ConfigDataNode; import com.bencodez.votingplugin.proxy.cache.DataNode; import com.bencodez.votingplugin.proxy.cache.IVoteCache; +import com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; +import com.bencodez.votingplugin.util.DurableFiles; + +import org.spongepowered.configurate.gson.GsonConfigurationLoader; /** * JSON-based vote cache implementation for Velocity proxy. @@ -23,6 +34,26 @@ public VelocityJsonVoteCache(File file) { super(file); } + @Override + public java.nio.file.Path getStoragePath() { + return getPath(); + } + + @Override + public synchronized void saveDurably() throws IOException { + Path target = getStoragePath().toAbsolutePath().normalize(); + Path parent = target.getParent(); + if (parent == null) throw new IOException("Vote cache has no parent directory"); + Files.createDirectories(parent); + Path staged = Files.createTempFile(parent, target.getFileName().toString(), ".tmp"); + try { + GsonConfigurationLoader.builder().path(staged).build().save(getConf()); + DurableFiles.publishStagedFile(staged, target); + } finally { + Files.deleteIfExists(staged); + } + } + @Override public void addTimedVote(int num, VoteTimeQueue voteTimedQueue) { setPath(voteTimedQueue.getName(), "TimedVoteCache", String.valueOf(num), "Name"); @@ -35,9 +66,27 @@ public void addTimedVote(int num, VoteTimeQueue voteTimedQueue) { "ProxyBroadcastHandled"); setPath(voteTimedQueue.getTotals(), "TimedVoteCache", String.valueOf(num), "Totals"); setPath(voteTimedQueue.isProcessed(), "TimedVoteCache", String.valueOf(num), "Processed"); + setPath(voteTimedQueue.isMultiProxyForwardingHandled(), "TimedVoteCache", String.valueOf(num), + "MultiProxyForwardingHandled"); + setPath(voteTimedQueue.isMultiProxyForwardingRequired(), "TimedVoteCache", String.valueOf(num), + "MultiProxyForwardingRequired"); + setPath(voteTimedQueue.isMultiProxyCapabilityDiscoveryPending(), "TimedVoteCache", String.valueOf(num), + "MultiProxyCapabilityDiscoveryPending"); + setPath(voteTimedQueue.isRealVote(), "TimedVoteCache", String.valueOf(num), "RealVote"); + setPath(voteTimedQueue.getMultiProxyOrigin(), "TimedVoteCache", String.valueOf(num), "MultiProxyOrigin"); + setPath(voteTimedQueue.isMultiProxyCompletionPending(), "TimedVoteCache", String.valueOf(num), + "MultiProxyCompletionPending"); + setPath(voteTimedQueue.encodeMultiProxyRecipients(), "TimedVoteCache", String.valueOf(num), + "MultiProxyRecipients"); + setPath(voteTimedQueue.encodeMultiProxyAcknowledgedServers(), "TimedVoteCache", String.valueOf(num), + "MultiProxyAcknowledgedServers"); + setPath(voteTimedQueue.encodeMultiProxyLegacyPendingRecipients(), "TimedVoteCache", String.valueOf(num), + "MultiProxyLegacyPendingRecipients"); setPath(voteTimedQueue.encodeBroadcastTargets(), "TimedVoteCache", String.valueOf(num), "BroadcastTargets"); setPath(voteTimedQueue.encodeBroadcastForwardedServers(), "TimedVoteCache", String.valueOf(num), "BroadcastForwardedServers"); + setPath(voteTimedQueue.encodeHttpBroadcastDeliveryIds(), "TimedVoteCache", String.valueOf(num), + "HttpBroadcastDeliveryIds"); } @Override @@ -56,6 +105,8 @@ public void addVote(String server, int num, OfflineBungeeVote voteData) { setPath(voteData.encodeBroadcastForwardedServers(), "VoteCache", server, String.valueOf(num), "BroadcastForwardedServers"); setPath(voteData.isRewardDelivered(), "VoteCache", server, String.valueOf(num), "RewardDelivered"); + setPath(voteData.encodeHttpDeliveryIds(), "VoteCache", server, String.valueOf(num), "HttpDeliveryIds"); + setPath(voteData.encodeHttpBroadcastDeliveryIds(), "VoteCache", server, String.valueOf(num), "HttpBroadcastDeliveryIds"); } @Override @@ -74,6 +125,8 @@ public void addVoteOnline(String player, int num, OfflineBungeeVote voteData) { setPath(voteData.encodeBroadcastForwardedServers(), "OnlineCache", player, String.valueOf(num), "BroadcastForwardedServers"); setPath(voteData.isRewardDelivered(), "OnlineCache", player, String.valueOf(num), "RewardDelivered"); + setPath(voteData.encodeHttpDeliveryIds(), "OnlineCache", player, String.valueOf(num), "HttpDeliveryIds"); + setPath(voteData.encodeHttpBroadcastDeliveryIds(), "OnlineCache", player, String.valueOf(num), "HttpBroadcastDeliveryIds"); } @Override @@ -134,6 +187,37 @@ public int getVotePartyCache(String server) { return getNode("VoteParty", "Cache", server).getInt(0); } + @Override + public Collection getPendingVotePartyRewardServers() { + Collection encoded = getKeys(getNode("VoteParty", "PendingRewards")); + Collection servers = new ArrayList<>(); + if (encoded != null) for (String key : encoded) try { + servers.add(decodeServerKey(key)); + } catch (IllegalArgumentException malformedKey) { + // Preserve later valid deliveries when one persisted key is malformed. + } + return servers; + } + + @Override + public Collection getPendingVotePartyRewardIds(String server) { + return getKeys(getNode("VoteParty", "PendingRewards", encodeServerKey(server))); + } + + @Override + public PendingVotePartyProxyEffects getPendingVotePartyProxyEffects() { + return new PendingVotePartyProxyEffects( + getString(getNode("VoteParty", "PendingProxyEffects", "Broadcast"), ""), + getStringList(getNode("VoteParty", "PendingProxyEffects", "Commands"), java.util.List.of())); + } + + @Override + public PendingVotePartyProxyEffects getQuarantinedVotePartyProxyEffects() { + return new PendingVotePartyProxyEffects( + getString(getNode("VoteParty", "QuarantinedProxyEffects", "Broadcast"), ""), + getStringList(getNode("VoteParty", "QuarantinedProxyEffects", "Commands"), java.util.List.of())); + } + @Override public int getVotePartyCurrentVotes() { return getInt(getNode("VoteParty", "CurrentVotes"), 0); @@ -149,6 +233,46 @@ public void setVotePartyCache(String server, int amount) { setPath(amount, "VoteParty", "Cache", server); } + @Override + public void setPendingVotePartyReward(String server, String deliveryId, boolean pending) { + String serverKey = encodeServerKey(server); + if (pending) setPath(true, "VoteParty", "PendingRewards", serverKey, deliveryId); + else { + remove("VoteParty", "PendingRewards", serverKey, deliveryId); + Collection remaining = getKeys(getNode("VoteParty", "PendingRewards", serverKey)); + if (remaining == null || remaining.isEmpty()) remove("VoteParty", "PendingRewards", serverKey); + } + } + + @Override + public void setPendingVotePartyProxyEffects(PendingVotePartyProxyEffects effects) { + if (effects.isEmpty()) { + remove("VoteParty", "PendingProxyEffects"); + return; + } + set(new Object[] { "VoteParty", "PendingProxyEffects", "Broadcast" }, effects.broadcast()); + set(new Object[] { "VoteParty", "PendingProxyEffects", "Commands" }, effects.commands()); + } + + @Override + public void setQuarantinedVotePartyProxyEffects(PendingVotePartyProxyEffects effects) { + if (effects.isEmpty()) { + remove("VoteParty", "QuarantinedProxyEffects"); + return; + } + set(new Object[] { "VoteParty", "QuarantinedProxyEffects", "Broadcast" }, effects.broadcast()); + set(new Object[] { "VoteParty", "QuarantinedProxyEffects", "Commands" }, effects.commands()); + } + + private static String encodeServerKey(String server) { + return Base64.getUrlEncoder().withoutPadding() + .encodeToString(server.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8)); + } + + private static String decodeServerKey(String server) { + return new String(Base64.getUrlDecoder().decode(server), StandardCharsets.UTF_8); + } + @Override public void setVotePartyCurrentVotes(int amount) { setPath(amount, "VoteParty", "CurrentVotes"); @@ -195,6 +319,10 @@ public void removeServerVote(String server, String uuid) { @Override public void removeVote(String server, OfflineBungeeVote vote) { + if (vote.getServerVoteCacheJsonKey() != null) { + remove("VoteCache", server, vote.getServerVoteCacheJsonKey()); + return; + } Collection votes = getServerVotes(server); if (votes == null) { return; @@ -204,6 +332,14 @@ public void removeVote(String server, OfflineBungeeVote vote) { if (node == null) { continue; } + DataNode voteIdNode = node.has("VoteId") ? node.get("VoteId") + : node.has("VoteID") ? node.get("VoteID") : null; + if (vote.getVoteId() != null && voteIdNode != null) { + if (vote.getVoteId().toString().equals(voteIdNode.asString())) { + remove("VoteCache", server, num); + } + continue; + } DataNode uuidNode = node.get("UUID"); DataNode serviceNode = node.get("Service"); DataNode timeNode = node.get("Time"); @@ -223,6 +359,10 @@ public void removeVote(String server, OfflineBungeeVote vote) { @Override public void removeOnlineVote(OfflineBungeeVote vote) { + if (vote.getOnlineVoteCacheJsonKey() != null && vote.getUuid() != null) { + remove("OnlineCache", vote.getUuid(), vote.getOnlineVoteCacheJsonKey()); + return; + } Collection players = getPlayers(); if (players == null) { return; @@ -237,6 +377,14 @@ public void removeOnlineVote(OfflineBungeeVote vote) { if (node == null) { continue; } + DataNode voteIdNode = node.has("VoteId") ? node.get("VoteId") + : node.has("VoteID") ? node.get("VoteID") : null; + if (vote.getVoteId() != null && voteIdNode != null) { + if (vote.getVoteId().toString().equals(voteIdNode.asString())) { + remove("OnlineCache", player, num); + } + continue; + } DataNode uuidNode = node.get("UUID"); DataNode serviceNode = node.get("Service"); DataNode timeNode = node.get("Time"); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VoteEventVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VoteEventVelocity.java index b0199f28f..364490960 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VoteEventVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VoteEventVelocity.java @@ -1,6 +1,10 @@ package com.bencodez.votingplugin.proxy.velocity; +import java.util.concurrent.TimeUnit; +import java.util.UUID; + import com.bencodez.votingplugin.util.MinecraftUsernameValidator; +import com.bencodez.votingplugin.proxy.VotingPluginProxy.VoteRetryException; import com.velocitypowered.api.event.Subscribe; import com.vexsoftware.votifier.velocity.event.VotifierEvent; @@ -26,20 +30,40 @@ public VoteEventVelocity(VotingPluginVelocity plugin) { public void onVotifierEvent(VotifierEvent event) { final String serviceSiteVote = event.getVote().getServiceName(); final String name = event.getVote().getUsername(); - plugin.getTimer().execute(new Runnable() { - - @Override - public void run() { - String serviceSite = serviceSiteVote; - plugin.getLogger().info("Vote received " + MinecraftUsernameValidator.sanitizeForLog(name) - + " from service site " + MinecraftUsernameValidator.sanitizeForLog(serviceSite)); - if (serviceSite.isEmpty()) { - serviceSite = "Empty"; - } + plugin.getTimer().execute(new RetryingVote(name, + serviceSiteVote == null || serviceSiteVote.isEmpty() ? "Empty" : serviceSiteVote)); - plugin.getVotingPluginProxy().vote(name, serviceSite, true, false, 0, null, null); - } - }); + } + private final class RetryingVote implements Runnable { + private static final int MAX_ATTEMPTS = 12; + private final String player; + private final String service; + private final UUID voteId = UUID.randomUUID(); + private int attempts; + + private RetryingVote(String player, String service) { + this.player = player; + this.service = service; + } + + @Override + public void run() { + plugin.getLogger().info("Vote received " + MinecraftUsernameValidator.sanitizeForLog(player) + + " from service site " + MinecraftUsernameValidator.sanitizeForLog(service)); + try { + plugin.getVotingPluginProxy().vote(player, service, true, false, 0, null, null, voteId); + } catch (VoteRetryException retryable) { + attempts++; + if (attempts < MAX_ATTEMPTS) { + plugin.getLogger().warn("Vote processing is waiting for durable storage; retrying shortly"); + plugin.getTimer().schedule(this, 5, TimeUnit.SECONDS); + } else { + plugin.getVotingPluginProxy().abandonLiveVoteRetry(voteId); + plugin.getLogger().error("Vote processing exhausted bounded durable-storage retries for {}", + MinecraftUsernameValidator.sanitizeForLog(player)); + } + } + } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java index 176c045c6..cad7d0f46 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/proxy/velocity/VotingPluginVelocity.java @@ -13,6 +13,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.security.CodeSource; +import java.util.Collection; import java.util.HashSet; import java.util.Locale; import java.util.Map.Entry; @@ -23,6 +24,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; import java.util.zip.ZipEntry; import java.util.zip.ZipInputStream; @@ -464,6 +466,19 @@ public void reloadAllInternal(boolean loadMysql) { // ========================= // FULL RELOAD (WITH MYSQL) // ========================= + // Preserve the current live HTTP listener while it owns retained durable + // deliveries. A fresh runtime would read the newly loaded endpoint, which + // may have been removed or changed before that FIFO is acknowledged. + if (votingPluginProxy != null + && votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()) { + votingPluginProxy.reload(); + if (votingPluginProxy.isRetainingHttpTransportForDeferredReconciliation()) { + scheduleTasks(); + reloading = false; + drainQueuedPluginMessagesAfterReloadLock(); + return; + } + } // Best-effort save caches before shutdown try { @@ -569,6 +584,26 @@ public void reloadAllInternal(boolean loadMysql) { } } + /** The retention branch returns from inside reloadLock; drain only after that lock is released. */ + private void drainQueuedPluginMessagesAfterReloadLock() { + Thread drain = new Thread(() -> { + synchronized (reloadLock) { + // Acquire/release establishes that the returning reload has left its lock. + } + drainQueuedPluginMessages(); + }, "VotingPlugin-Velocity-Reload-Queue-Drain"); + drain.setDaemon(true); + drain.start(); + } + + /** Runs a scheduled deferred replacement only if it is still current while holding reloadLock. */ + private void reloadAllInternalIfCurrent(boolean loadMysql, BooleanSupplier stillCurrent) { + synchronized (reloadLock) { + if (!stillCurrent.getAsBoolean()) return; + reloadAllInternal(loadMysql); + } + } + /** * Create proxy runtime. * @@ -714,6 +749,26 @@ public int getVoteCacheVotePartyIncreaseVotesRequired() { return voteCacheFile.getVotePartyInreaseVotesRequired(); } + @Override + public Collection getVoteCachePendingVotePartyServers() { + return voteCacheFile.getPendingVotePartyRewardServers(); + } + + @Override + public Collection getVoteCachePendingVotePartyRewardIds(String server) { + return voteCacheFile.getPendingVotePartyRewardIds(server); + } + + @Override + public com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects getVoteCachePendingVotePartyProxyEffects() { + return voteCacheFile.getPendingVotePartyProxyEffects(); + } + + @Override + public com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects getVoteCacheQuarantinedVotePartyProxyEffects() { + return voteCacheFile.getQuarantinedVotePartyProxyEffects(); + } + @Override public boolean isVoteCacheIgnoreTime() { return voteCacheFile.getNode("Time", "IgnoreTime").getBoolean(); @@ -759,6 +814,23 @@ public void setVoteCacheVotePartyIncreaseVotesRequired(int votes) { voteCacheFile.setVotePartyInreaseVotesRequired(votes); } + @Override + public void setVoteCachePendingVotePartyReward(String server, String deliveryId, boolean pending) { + voteCacheFile.setPendingVotePartyReward(server, deliveryId, pending); + } + + @Override + public void setVoteCachePendingVotePartyProxyEffects( + com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects effects) { + voteCacheFile.setPendingVotePartyProxyEffects(effects); + } + + @Override + public void setVoteCacheQuarantinedVotePartyProxyEffects( + com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects effects) { + voteCacheFile.setQuarantinedVotePartyProxyEffects(effects); + } + @Override public boolean isPlayerOnline(String playerName) { if (playerName == null) { @@ -800,11 +872,26 @@ public void runConsoleCommand(String command) { server.getCommandManager().executeAsync(server.getConsoleCommandSource(), command); } + @Override + protected java.util.concurrent.CompletableFuture runVotePartyConsoleCommand(String command) { + return server.getCommandManager().executeAsync(server.getConsoleCommandSource(), command) + .thenApply(executed -> { + if (!executed) throw new IllegalStateException("Velocity declined the vote-party proxy command"); + return null; + }); + } + @Override public void saveVoteCacheFile() { voteCacheFile.save(); } + @Override + public void saveVotePartyStateDurably() throws java.io.IOException { + com.bencodez.votingplugin.proxy.cache.VotePartyCacheDurability.saveAndVerify( + dataDirectory.resolve("votecache.json"), voteCacheFile); + } + @Override public boolean sendPluginMessageData(String serverName, String channelName, byte[] data, boolean queue) { if (!server.getServer(serverName).isPresent()) { @@ -825,6 +912,11 @@ public void reloadCore(boolean mysql) { reloadAllInternal(mysql); } + @Override + protected void reloadDeferredHttpTransportCore(long generation) { + reloadAllInternalIfCurrent(true, () -> isDeferredHttpTransportGenerationCurrent(generation)); + } + @Override public void reloadControlConfiguration() throws Exception { config.loadControlConfiguration(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/servicesites/ServiceSiteHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/servicesites/ServiceSiteHandler.java index 356cddc7c..1f52833c0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/servicesites/ServiceSiteHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/servicesites/ServiceSiteHandler.java @@ -21,6 +21,7 @@ import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -228,6 +229,20 @@ static byte[] readSuccessfulResponse(HttpResponse response, long ti throw new java.io.UncheckedIOException(e); } }); + AtomicBoolean finished = new AtomicBoolean(); + Thread deadline = new Thread(() -> { + try { + TimeUnit.NANOSECONDS.sleep(unit.toNanos(timeout)); + if (finished.compareAndSet(false, true)) input.close(); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } catch (IOException ignored) { + // The reader will report the resulting failure to the caller. + } + }, "VotingPlugin-ServiceSite-ReadDeadline"); + deadline.setDaemon(true); + deadline.setContextClassLoader(ClassLoader.getPlatformClassLoader()); + deadline.start(); try { return read.get(timeout, unit); } catch (TimeoutException e) { @@ -242,6 +257,9 @@ static byte[] readSuccessfulResponse(HttpResponse response, long ti read.cancel(true); Thread.currentThread().interrupt(); throw new IOException("Interrupted while reading service site response", e); + } finally { + finished.set(true); + deadline.interrupt(); } } } 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 7f3f5cf9a..3d6f548c1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/VoteTimeQueue.java @@ -4,6 +4,9 @@ import java.util.Base64; import java.util.Collections; import java.util.LinkedHashSet; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -14,6 +17,7 @@ * Represents a vote delayed while a proxy time change is active. */ public class VoteTimeQueue { + private static final String MULTI_PROXY_RETIRED_PREFIX = "retired:"; @Getter @Setter private String name; @@ -26,6 +30,14 @@ public class VoteTimeQueue { @Getter @Setter private UUID voteId; + /** SQL primary key for a legacy timed-cache row, or {@code -1} when not SQL-backed. */ + @Getter + @Setter + private int timedVoteCacheRowId = -1; + /** Stable key of a JSON timed-cache entry, or {@code null} when not JSON-backed. */ + @Getter + @Setter + private String timedVoteCacheJsonKey; @Getter @Setter private String uuid; @@ -38,6 +50,35 @@ public class VoteTimeQueue { @Getter @Setter private boolean processed; + /** Whether multi-proxy forwarding was durably handled for this queued vote. */ + @Getter + @Setter + private boolean multiProxyForwardingHandled; + /** True when this row is a durable multi-proxy delivery outbox. */ + @Getter + @Setter + private boolean multiProxyForwardingRequired; + /** + * The forwarding intent is durable, but peer capability discovery has not + * finished yet. This is deliberately distinct from an ACK outbox with an + * empty recipient set: recovery must classify the configured peers before it + * can choose either the durable or legacy route. + */ + @Getter + @Setter + private boolean multiProxyCapabilityDiscoveryPending; + /** Original vote type; legacy rows default to a real vote. */ + @Getter + @Setter + private boolean realVote = true; + /** Sender identity used to route acknowledgement envelopes. */ + @Getter + @Setter + private String multiProxyOrigin = ""; + /** Receiver completion is durable in this row but still needs a completion tombstone/ACK. */ + @Getter + @Setter + private boolean multiProxyCompletionPending; @Getter @Setter private boolean deliveryStateDirty; @@ -45,6 +86,17 @@ public class VoteTimeQueue { private Set broadcastTargets; @Getter private Set broadcastForwardedServers; + /** Configured recipient proxy names for the durable multi-proxy outbox. */ + @Getter + private Set multiProxyRecipients; + /** Recipients whose durable completion acknowledgement was received. */ + @Getter + private Set multiProxyAcknowledgedServers; + /** Legacy peers whose one-way copy has not yet been accepted by the transport. */ + @Getter + private Set multiProxyLegacyPendingRecipients; + /** Stable HTTP standalone-broadcast delivery IDs by target server. */ + private final Map httpBroadcastDeliveryIds; /** * Creates a legacy-compatible queued vote without an identifier. @@ -85,6 +137,25 @@ public VoteTimeQueue(UUID voteId, String name, String service, long time, boolea false, ""); } + /** + * Returns the durable identity of a legacy timed-cache entry. It distinguishes + * rows that have the same historical vote fields before they receive a vote ID. + */ + public String getTimedVoteCachePersistenceIdentity() { + if (timedVoteCacheRowId > 0) return "sql:" + timedVoteCacheRowId; + if (timedVoteCacheJsonKey != null && !timedVoteCacheJsonKey.isEmpty()) { + return "json:" + timedVoteCacheJsonKey; + } + return ""; + } + + /** Creates the deterministic ID used when a legacy timed-cache entry has none. */ + public UUID legacyTimedVoteId() { + String identity = "legacy-timed-vote\u0000" + uuid + "\u0000" + name + "\u0000" + service + "\u0000" + + time + "\u0000" + getTimedVoteCachePersistenceIdentity(); + return UUID.nameUUIDFromBytes(identity.getBytes(StandardCharsets.UTF_8)); + } + /** * Creates a queued vote with the original proxy broadcast routing state. * @@ -124,6 +195,26 @@ public VoteTimeQueue(UUID voteId, String name, String service, long time, boolea public VoteTimeQueue(UUID voteId, String name, String service, long time, boolean proxyBroadcastHandled, Set broadcastTargets, Set broadcastForwardedServers, String totals, boolean processed, String uuid) { + this(voteId, name, service, time, proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, totals, + processed, uuid, Collections.emptyMap()); + } + + /** Creates a queued vote with persisted HTTP standalone-broadcast IDs. */ + public VoteTimeQueue(UUID voteId, String name, String service, long time, boolean proxyBroadcastHandled, + Set broadcastTargets, Set broadcastForwardedServers, String totals, boolean processed, + String uuid, Map httpBroadcastDeliveryIds) { + this(voteId, name, service, time, proxyBroadcastHandled, broadcastTargets, broadcastForwardedServers, totals, + processed, false, uuid, httpBroadcastDeliveryIds); + } + + /** + * Creates a queued vote with all durable delivery fences. + * + * @param multiProxyForwardingHandled whether multi-proxy forwarding already completed + */ + public VoteTimeQueue(UUID voteId, String name, String service, long time, boolean proxyBroadcastHandled, + Set broadcastTargets, Set broadcastForwardedServers, String totals, boolean processed, + boolean multiProxyForwardingHandled, String uuid, Map httpBroadcastDeliveryIds) { this.voteId = voteId; this.uuid = uuid == null ? "" : uuid; this.name = name; @@ -132,6 +223,7 @@ public VoteTimeQueue(UUID voteId, String name, String service, long time, boolea this.proxyBroadcastHandled = proxyBroadcastHandled; this.totals = totals == null ? "" : totals; this.processed = processed; + this.multiProxyForwardingHandled = multiProxyForwardingHandled; this.broadcastTargets = new LinkedHashSet<>(); if (broadcastTargets != null) { this.broadcastTargets.addAll(broadcastTargets); @@ -140,6 +232,164 @@ public VoteTimeQueue(UUID voteId, String name, String service, long time, boolea if (broadcastForwardedServers != null) { this.broadcastForwardedServers.addAll(broadcastForwardedServers); } + this.multiProxyRecipients = new LinkedHashSet<>(); + this.multiProxyAcknowledgedServers = new LinkedHashSet<>(); + this.multiProxyLegacyPendingRecipients = new LinkedHashSet<>(); + this.httpBroadcastDeliveryIds = new LinkedHashMap<>(); + if (httpBroadcastDeliveryIds != null) { + httpBroadcastDeliveryIds.forEach(this::setHttpBroadcastDeliveryId); + } + } + + /** Configures the durable acknowledgement fence before the first send. */ + public void requireMultiProxyAcknowledgements(String origin, Set recipients) { + multiProxyForwardingRequired = true; + multiProxyOrigin = origin == null ? "" : origin; + multiProxyRecipients.clear(); + if (recipients != null) { + for (String recipient : recipients) { + if (recipient != null && !recipient.isBlank()) { + multiProxyRecipients.add(recipient.toLowerCase(Locale.ROOT)); + } + } + } + } + + /** Adds an acknowledgement only for a configured recipient. */ + public boolean acknowledgeMultiProxyRecipient(String recipient) { + if (recipient == null || recipient.isBlank()) return false; + String normalized = recipient.toLowerCase(Locale.ROOT); + if (!multiProxyRecipients.contains(normalized)) return false; + return multiProxyAcknowledgedServers.add(normalized); + } + + /** Returns whether every intended receiver durably acknowledged the vote. */ + public boolean hasCompletedMultiProxyAcknowledgements() { + return multiProxyForwardingRequired && !multiProxyRecipients.isEmpty() + && multiProxyAcknowledgedServers.containsAll(multiProxyRecipients); + } + + /** Records that a receiver durably removed its completion fence. */ + public boolean acknowledgeMultiProxyRetirement(String recipient) { + if (recipient == null || recipient.isBlank()) return false; + String normalized = recipient.toLowerCase(Locale.ROOT); + if (!multiProxyRecipients.contains(normalized)) return false; + return multiProxyAcknowledgedServers.add(MULTI_PROXY_RETIRED_PREFIX + normalized); + } + + /** Returns whether every receiver acknowledged completion-fence retirement. */ + public boolean hasCompletedMultiProxyRetirements() { + if (!hasCompletedMultiProxyAcknowledgements()) return false; + for (String recipient : multiProxyRecipients) { + if (!multiProxyAcknowledgedServers.contains(MULTI_PROXY_RETIRED_PREFIX + recipient)) return false; + } + return true; + } + + /** Returns receivers that still need an idempotent retirement request. */ + public Set getPendingMultiProxyRetirements() { + Set pending = new LinkedHashSet<>(); + for (String recipient : multiProxyRecipients) { + if (!multiProxyAcknowledgedServers.contains(MULTI_PROXY_RETIRED_PREFIX + recipient)) pending.add(recipient); + } + return pending; + } + + public String encodeMultiProxyRecipients() { + return encodeBroadcastServers(multiProxyRecipients); + } + + public String encodeMultiProxyLegacyPendingRecipients() { + return encodeBroadcastServers(multiProxyLegacyPendingRecipients); + } + + public String encodeMultiProxyAcknowledgedServers() { + return encodeBroadcastServers(multiProxyAcknowledgedServers); + } + + public void setMultiProxyRecipients(Set recipients) { + multiProxyRecipients.clear(); + if (recipients != null) { + for (String recipient : recipients) { + if (recipient != null && !recipient.isBlank()) { + multiProxyRecipients.add(recipient.toLowerCase(Locale.ROOT)); + } + } + } + } + + public void setMultiProxyLegacyPendingRecipients(Set recipients) { + multiProxyLegacyPendingRecipients.clear(); + if (recipients != null) { + for (String recipient : recipients) { + if (recipient != null && !recipient.isBlank()) multiProxyLegacyPendingRecipients.add(recipient); + } + } + } + + public void setMultiProxyAcknowledgedServers(Set recipients) { + multiProxyAcknowledgedServers.clear(); + if (recipients != null) { + for (String recipient : recipients) { + if (recipient != null && !recipient.isBlank()) { + multiProxyAcknowledgedServers.add(recipient.toLowerCase(Locale.ROOT)); + } + } + } + } + + public String getHttpBroadcastDeliveryId(String server) { + return server == null ? null : httpBroadcastDeliveryIds.get(server.toLowerCase(Locale.ROOT)); + } + + public void setHttpBroadcastDeliveryId(String server, String deliveryId) { + if (server == null || server.isBlank()) return; + String key = server.toLowerCase(Locale.ROOT); + if (deliveryId == null || deliveryId.isBlank()) httpBroadcastDeliveryIds.remove(key); + else httpBroadcastDeliveryIds.put(key, deliveryId); + } + + public Map getHttpBroadcastDeliveryIds() { + return new LinkedHashMap<>(httpBroadcastDeliveryIds); + } + + /** Returns whether any standalone HTTP broadcast still has to be delivered. */ + public boolean hasPendingHttpBroadcastDeliveryIds() { + return !httpBroadcastDeliveryIds.isEmpty(); + } + + public String encodeHttpBroadcastDeliveryIds() { + StringBuilder encoded = new StringBuilder(); + for (Map.Entry entry : httpBroadcastDeliveryIds.entrySet()) { + if (encoded.length() > 0) encoded.append('.'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getKey().getBytes(StandardCharsets.UTF_8))); + encoded.append('~'); + encoded.append(Base64.getUrlEncoder().withoutPadding() + .encodeToString(entry.getValue().getBytes(StandardCharsets.UTF_8))); + } + return encoded.toString(); + } + + public static Map decodeHttpBroadcastDeliveryIds(String encoded) { + Map decoded = new LinkedHashMap<>(); + if (encoded == null || encoded.isBlank()) return decoded; + for (String entry : encoded.split("\\.", -1)) { + int separator = entry.indexOf('~'); + if (separator <= 0 || separator == entry.length() - 1) continue; + try { + String server = new String(Base64.getUrlDecoder().decode(entry.substring(0, separator)), + StandardCharsets.UTF_8); + String deliveryId = new String(Base64.getUrlDecoder().decode(entry.substring(separator + 1)), + StandardCharsets.UTF_8); + if (!server.isBlank() && deliveryId.matches("[0-9a-fA-F-]{36}")) { + decoded.put(server.toLowerCase(Locale.ROOT), deliveryId); + } + } catch (IllegalArgumentException ignored) { + // Ignore corrupt optional delivery state and retain the queued vote. + } + } + return decoded; } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java index b57fdb72f..9122b1e61 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/DurableFiles.java @@ -7,6 +7,7 @@ import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardOpenOption; +import java.nio.file.StandardCopyOption; import java.util.Locale; /** Cross-platform helpers for forcing file contents and published directory entries. */ @@ -34,6 +35,17 @@ public static void forceDirectory(Path directory) throws IOException { } } + /** Forces a staged file, atomically publishes it, then forces the directory entry. */ + public static void publishStagedFile(Path staged, Path target) throws IOException { + forceFile(staged); + Files.move(staged, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + try { + forceDirectory(target.toAbsolutePath().normalize().getParent()); + } catch (IOException failure) { + throw new PublishedException(failure); + } + } + public static boolean deleteIfExists(Path target) throws IOException { boolean deleted = Files.deleteIfExists(target); if (deleted) forceDirectory(target.toAbsolutePath().normalize().getParent()); diff --git a/VotingPlugin/src/main/resources/BungeeSettings.yml b/VotingPlugin/src/main/resources/BungeeSettings.yml index 036566edc..a8310126d 100644 --- a/VotingPlugin/src/main/resources/BungeeSettings.yml +++ b/VotingPlugin/src/main/resources/BungeeSettings.yml @@ -10,7 +10,7 @@ # 📑 Config Index (matches this file) # ───────────────────────────────────────────────────────────────────────────── # • Core proxy toggle (UseBungeecord) + method selection (BungeeMethod) -# • Method configs (PLUGINMESSAGING / REDIS / MQTT / MYSQL / SOCKETS) +# • Method configs (PLUGINMESSAGING / HTTP / REDIS / MQTT / MYSQL / SOCKETS) # • Broadcast behavior (BungeeBroadcast / DisableBroadcast / Always) # • Per-server behavior (PerServerRewards / Milestones / Points) # • VoteParty (proxy rewards + global commands) @@ -61,12 +61,19 @@ UseBungeecord: false # Requires restart and set on all servers # https://github.com/BenCodez/VotingPlugin/wiki/Bungeecord-Setups # Available: -# PLUGINMESSAGING (Recommended) -# REDIS (2nd Most Recommended) +# PLUGINMESSAGING (Recommended) +# HTTP (secure direct connection; one proxy port) +# REDIS (2nd Most Recommended) # MQTT # MYSQL # SOCKETS (Not recommended) -BungeeMethod: PLUGINMESSAGING +BungeeMethod: PLUGINMESSAGING + +# Secure direct transport. The proxy generates a temporary ConnectionCode. +# Paste it here once, start the backend, then remove it after enrollment succeeds. +# The backend stores its private client identity in the VotingPlugin data folder. +HTTP: + ConnectionCode: '' # Use Redis for between server communication, set BungeeMethod to REDIS to use this Redis: diff --git a/VotingPlugin/src/main/resources/bungeeconfig.yml b/VotingPlugin/src/main/resources/bungeeconfig.yml index e4e5eb180..6effd4c26 100644 --- a/VotingPlugin/src/main/resources/bungeeconfig.yml +++ b/VotingPlugin/src/main/resources/bungeeconfig.yml @@ -250,17 +250,31 @@ WhiteListedServers: [] # The proxy runtime is replaced only after its durable result is acknowledged. # https://github.com/BenCodez/VotingPlugin/wiki/Bungeecoord-Setups # Available: -# PLUGINMESSAGING (Recommended) -# REDIS +# PLUGINMESSAGING (Recommended) +# HTTP (secure direct connection; one proxy port) +# REDIS # MQTT # SOCKETS # MYSQL (Not recommended) # PLUGINMESSAGING uses this proxy's player/server state and disables backend # presence messages. Other methods trust each backend's configured Server name. -BungeeMethod: PLUGINMESSAGING +BungeeMethod: PLUGINMESSAGING ###################################################################################### -# Settings for each setup below +# Settings for each setup below + +########################################### +# HTTP Settings +########################################### + +# HTTP uses one encrypted listener on the proxy. Backends connect outbound, so +# they do not need an exposed port. PublicEndpoint must be the HTTPS URL that +# backend servers can reach. VotingPlugin creates the TLS identity, enrollment +# codes, and per-backend client certificates automatically. +HTTP: + Host: '0.0.0.0' + Port: 1297 + PublicEndpoint: '' ########################################### # PLUGINMESSAGING settings diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java new file mode 100644 index 000000000..00caae4ab --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/VotingPluginMainBackendProxyPublicationTest.java @@ -0,0 +1,634 @@ +package com.bencodez.votingplugin; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.configuration.file.FileConfiguration; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.votingplugin.backendproxy.BackendProxyHandler; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.config.Config; +import com.bencodez.votingplugin.proxy.BungeeMethod; + +class VotingPluginMainBackendProxyPublicationTest { + @Test + void deferredSameMethodRestartPreparesBeforeLoadingReplacement() throws Exception { + for (BungeeMethod method : new BungeeMethod[] { BungeeMethod.SOCKETS, BungeeMethod.MQTT }) { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BungeeSettings settings = mock(BungeeSettings.class); + setBackendProxyHandler(plugin, previous); + setField(plugin, "bungeeSettings", settings); + when(settings.isUseBungeecoord()).thenReturn(true); + when(settings.getBungeeMethod()).thenReturn(method.name()); + when(previous.getMethod()).thenReturn(method); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.eq(method), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + try (var constructed = org.mockito.Mockito.mockConstruction(BackendProxyHandler.class)) { + VotingPluginMain.BackendProxyRestart restart = plugin.prepareBackendProxyHandlerRestart(); + BackendProxyHandler replacement = constructed.constructed().get(0); + verify(replacement, never()).loadForReplacement(); + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + 1_000_000_000L); + verify(previous).prepareForReplacement(org.mockito.ArgumentMatchers.eq(method), + org.mockito.ArgumentMatchers.anyLong()); + verify(replacement).loadForReplacement(); + assertTrue(restart.requiresWorkerRollback()); + } + } + } + + @Test + void proxyMethodControlPreparationReloadsOnlyNarrowSettingsBeforeReturningTheStagedRestart() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + doCallRealMethod().when(plugin).prepareBackendProxyMethodRestartFromControl(); + BungeeSettings settings = mock(BungeeSettings.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + VotingPluginMain.BackendProxyRestart restart = mock(VotingPluginMain.BackendProxyRestart.class); + setField(plugin, "bungeeSettings", settings); + when(settings.getServer()).thenReturn("backend-1"); + when(plugin.getOptions()).thenReturn(options); + when(plugin.prepareBackendProxyHandlerRestart()).thenReturn(restart); + + assertSame(restart, plugin.prepareBackendProxyMethodRestartFromControl()); + + verify(settings).reloadData(); + verify(options).setServer("backend-1"); + verify(plugin).updateAdvancedCoreHook(); + verify(plugin).prepareBackendProxyHandlerRestart(); + verify(plugin, never()).reloadFromControl(); + } + + @Test + void publishesReplacementBeforeOpeningItsTransportGate() { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + doAnswer(invocation -> { + assertSame(replacement, plugin.getBackendProxyHandler(), + "inbound callbacks must not open before the replacement is published"); + return null; + }).when(replacement).activatePresenceReporting(); + + plugin.publishBackendProxyHandler(null, replacement); + + assertSame(replacement, plugin.getBackendProxyHandler()); + } + + @Test + void restoresPreviousHandlerWhenActivationFails() { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + doAnswer(invocation -> { throw new IllegalStateException("activation failed"); }) + .when(replacement).activatePresenceReporting(); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> plugin.publishBackendProxyHandler(previous, replacement)); + + assertSame(previous, plugin.getBackendProxyHandler()); + verify(replacement).abortStagedInboundTo(previous); + } + + @Test + void keepsPreparedHttpQueueWithPreviousHandlerUntilPublicationSucceeds() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + doAnswer(invocation -> { throw new IllegalStateException("activation failed"); }) + .when(replacement).activatePresenceReporting(); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> plugin.completeBackendProxyHandlerRestart(restart)); + + verify(previous, never()).completeHttpHandoff(replacement); + verify(previous, never()).completeRedisHandoff(replacement); + assertSame(previous, plugin.getBackendProxyHandler()); + } + + @Test + void failsRedisRetirementDuringWorkerValidationBeforeBukkitPublication() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisHandoff(replacement)).thenReturn(true); + doAnswer(invocation -> { throw new IllegalStateException("promotion failed"); }) + .when(previous).completeRedisHandoff(replacement); + + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> plugin.validateBackendProxyHandlerRestart(restart(previous, replacement), + System.nanoTime() + 1_000_000_000L)); + + assertSame(previous, plugin.getBackendProxyHandler()); + verify(previous, never()).completeHttpHandoff(replacement); + verify(previous, never()).close(); + verify(replacement, never()).abortStagedInboundTo(previous); + verify(replacement, never()).close(); + verify(previous, never()).refreshPresenceAfterFailedReplacement(); + } + + @Test + void preparesRedisBeforePublishingAndRetiresItOffTheBukkitThread() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisRetirement()).thenReturn(true); + when(previous.requiresRedisHandoff(replacement)).thenReturn(false); + when(replacement.getMethod()).thenReturn(BungeeMethod.HTTP); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.eq(BungeeMethod.HTTP), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + CountDownLatch closed = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference closeThread = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(invocation -> { + closeThread.set(Thread.currentThread().getName()); + closed.countDown(); + return null; + }).when(previous).close(); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement, true); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + + org.mockito.InOrder retirement = org.mockito.Mockito.inOrder(previous); + retirement.verify(previous).prepareForReplacement(org.mockito.ArgumentMatchers.eq(BungeeMethod.HTTP), + org.mockito.ArgumentMatchers.anyLong()); + verify(previous, never()).close(); + + plugin.closePublishedPreviousBackendProxyHandler(previous, "after publication"); + assertTrue(closed.await(1, TimeUnit.SECONDS)); + assertTrue(closeThread.get().startsWith("VotingPlugin-Retired-Backend")); + } + + @Test + void loadsDeferredSocketReplacementAfterPredecessorPreparation() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(replacement.getMethod()).thenReturn(BungeeMethod.SOCKETS); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.eq(BungeeMethod.SOCKETS), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement, true); + setRestartField(restart, "replacementLoadDeferred", true); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(previous, replacement); + order.verify(previous).prepareForReplacement(org.mockito.ArgumentMatchers.eq(BungeeMethod.SOCKETS), + org.mockito.ArgumentMatchers.anyLong()); + order.verify(replacement).loadForReplacement(); + order.verify(replacement).validateTransport(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void loadsDeferredMqttReplacementAfterPredecessorPreparation() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(replacement.getMethod()).thenReturn(BungeeMethod.MQTT); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.eq(BungeeMethod.MQTT), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement, true); + setRestartField(restart, "replacementLoadDeferred", true); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(previous, replacement); + order.verify(previous).prepareForReplacement(org.mockito.ArgumentMatchers.eq(BungeeMethod.MQTT), + org.mockito.ArgumentMatchers.anyLong()); + order.verify(replacement).loadForReplacement(); + order.verify(replacement).validateTransport(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void retiresNonRedisNetworkTransportOffTheBukkitThread() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + when(previous.getMethod()).thenReturn(BungeeMethod.MQTT); + CountDownLatch closeStarted = new CountDownLatch(1); + CountDownLatch releaseClose = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference closeThread = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(invocation -> { + closeThread.set(Thread.currentThread().getName()); + closeStarted.countDown(); + releaseClose.await(1, TimeUnit.SECONDS); + return null; + }).when(previous).close(); + + try { + org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofMillis(250), + () -> plugin.closePublishedPreviousBackendProxyHandler(previous, "after publication")); + assertTrue(closeStarted.await(1, TimeUnit.SECONDS)); + assertTrue(closeThread.get().startsWith("VotingPlugin-Retired-Backend")); + } finally { + releaseClose.countDown(); + } + verify(previous, org.mockito.Mockito.timeout(1_000)).close(); + } + + @Test + void preparesRedisBeforePublishingDisabledState() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisRetirement()).thenReturn(true); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + VotingPluginMain.BackendProxyRestart restart = restart(previous, null, true); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + + org.mockito.InOrder retirement = org.mockito.Mockito.inOrder(previous); + retirement.verify(previous).prepareForReplacement(org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyLong()); + verify(previous, never()).close(); + } + + @Test + void restoresWorkerRetiredRedisListenerWhenPublicationIsAborted() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisHandoff(replacement)).thenReturn(true); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + 1_000_000_000L); + plugin.abortBackendProxyHandlerRestart(restart); + + verify(previous).completeRedisHandoff(replacement); + verify(replacement, org.mockito.Mockito.timeout(1_000).times(1)).close(); + verify(previous).restoreAfterFailedReplacement(replacement); + } + + @Test + void controlWorkerRollbackRestoresPreparedPredecessorAfterStagedCloseFailure() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.getMethod()).thenReturn(BungeeMethod.MQTT); + when(replacement.getMethod()).thenReturn(BungeeMethod.MQTT); + doThrow(new IllegalStateException("candidate close failed")).when(replacement).close(); + AtomicReference restoreThread = new AtomicReference<>(); + doAnswer(ignored -> { + restoreThread.set(Thread.currentThread().getName()); + return null; + }).when(previous).restoreAfterFailedReplacement(); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement, true); + setRestartField(restart, "previousPrepared", true); + assertTrue(restart.requiresWorkerRollback()); + AtomicReference failure = new AtomicReference<>(); + Thread controlWorker = new Thread(() -> { + try { + plugin.abortBackendProxyHandlerRestart(restart); + } catch (RuntimeException expected) { + failure.set(expected); + } + }, "votingplugin-control-backend"); + + controlWorker.start(); + controlWorker.join(TimeUnit.SECONDS.toMillis(1)); + + assertFalse(controlWorker.isAlive()); + assertTrue(failure.get() instanceof IllegalStateException); + verify(replacement).close(); + verify(previous).restoreAfterFailedReplacement(); + assertTrue(restoreThread.get().startsWith("votingplugin-control-backend")); + } + + @Test + void exclusiveHandoffFailureDefersRollbackNetworkWorkToControlWorker() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.getMethod()).thenReturn(BungeeMethod.SOCKETS); + when(replacement.getMethod()).thenReturn(BungeeMethod.SOCKETS); + doThrow(new IllegalStateException("handoff admission failed")) + .when(previous).completeHttpHandoff(replacement); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement, true); + setRestartField(restart, "previousPrepared", true); + + assertThrows(IllegalStateException.class, () -> plugin.completeBackendProxyHandlerRestart(restart)); + + verify(replacement).abortStagedInboundTo(previous); + verify(replacement, never()).close(); + verify(previous, never()).restoreAfterFailedReplacement(); + AtomicReference restoreThread = new AtomicReference<>(); + doAnswer(ignored -> { + restoreThread.set(Thread.currentThread().getName()); + return null; + }).when(previous).restoreAfterFailedReplacement(); + Thread controlWorker = new Thread(() -> plugin.abortBackendProxyHandlerRestart(restart), + "votingplugin-control-backend"); + controlWorker.start(); + controlWorker.join(TimeUnit.SECONDS.toMillis(1)); + + assertFalse(controlWorker.isAlive()); + verify(replacement).close(); + verify(previous).restoreAfterFailedReplacement(); + assertTrue(restoreThread.get().startsWith("votingplugin-control-backend")); + } + + @Test + void rollbackRestoresPromotedRedisReplayBeforeClosingReplacement() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisHandoff(replacement)).thenReturn(true); + doThrow(new IllegalStateException("publication admission failed")) + .when(previous).completeHttpHandoff(replacement); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + + assertThrows(IllegalStateException.class, () -> plugin.completeBackendProxyHandlerRestart(restart)); + + org.mockito.InOrder rollback = org.mockito.Mockito.inOrder(previous, replacement); + rollback.verify(previous).restoreAfterFailedReplacement(replacement); + rollback.verify(replacement, org.mockito.Mockito.timeout(1_000).times(1)).close(); + } + + @Test + void refusesRedisPublicationUntilWorkerHandoffWasValidated() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisHandoff(replacement)).thenReturn(true); + + assertThrows(IllegalStateException.class, + () -> plugin.completeBackendProxyHandlerRestart(restart(previous, replacement))); + + assertSame(previous, plugin.getBackendProxyHandler()); + verify(previous, never()).completeRedisHandoff(replacement); + verify(replacement, never()).activateInboundMessages(); + } + + @Test + void abortWaitsForInProgressRedisHandoffAndRestoresItAfterValidation() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + when(previous.requiresRedisHandoff(replacement)).thenReturn(true); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(1, TimeUnit.SECONDS)); + return null; + }).when(previous).completeRedisHandoff(replacement); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + Thread validation = new Thread(() -> plugin.validateBackendProxyHandlerRestart(restart, + System.nanoTime() + TimeUnit.SECONDS.toNanos(1))); + validation.start(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + + plugin.abortBackendProxyHandlerRestart(restart); + verify(replacement, never()).close(); + release.countDown(); + validation.join(TimeUnit.SECONDS.toMillis(1)); + assertFalse(validation.isAlive(), "validation must finish and perform the requested rollback"); + + verify(replacement, org.mockito.Mockito.timeout(1_000).times(1)).close(); + verify(previous).restoreAfterFailedReplacement(replacement); + } + + @Test + void stagedRedisAbortFencesOnBukkitButClosesTheReplacementOffThread() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + Field completed = VotingPluginMain.BackendProxyRestart.class.getDeclaredField("redisHandoffCompleted"); + completed.setAccessible(true); + completed.setBoolean(restart, true); + CountDownLatch closeStarted = new CountDownLatch(1); + CountDownLatch releaseClose = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference closeThread = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(invocation -> { + closeThread.set(Thread.currentThread().getName()); + closeStarted.countDown(); + releaseClose.await(1, TimeUnit.SECONDS); + return null; + }).when(replacement).close(); + + try { + org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofMillis(250), + () -> plugin.abortBackendProxyHandlerRestart(restart)); + assertTrue(closeStarted.await(1, TimeUnit.SECONDS)); + assertTrue(closeThread.get().startsWith("VotingPlugin-Staged-Redis-Rollback")); + } finally { + releaseClose.countDown(); + } + verify(replacement, org.mockito.Mockito.timeout(1_000)).close(); + } + + @Test + void stagedRedisAbortBeforeHandoffClosesTheReplacementOffThread() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + when(replacement.getMethod()).thenReturn(BungeeMethod.REDIS); + setBackendProxyHandler(plugin, previous); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + CountDownLatch closeStarted = new CountDownLatch(1); + CountDownLatch releaseClose = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference closeThread = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(invocation -> { + closeThread.set(Thread.currentThread().getName()); + closeStarted.countDown(); + releaseClose.await(1, TimeUnit.SECONDS); + return null; + }).when(replacement).close(); + + try { + org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofMillis(250), + () -> plugin.abortBackendProxyHandlerRestart(restart)); + assertTrue(closeStarted.await(1, TimeUnit.SECONDS)); + assertTrue(closeThread.get().startsWith("VotingPlugin-Staged-Redis-Rollback")); + } finally { + releaseClose.countDown(); + } + verify(replacement, org.mockito.Mockito.timeout(1_000)).close(); + } + + @Test + void restoresPreviousHandlerWhenPreparedDeliveryAdmissionFails() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + doThrow(new IllegalStateException("handoff admission failed")) + .when(previous).completeHttpHandoff(replacement); + + assertThrows(IllegalStateException.class, + () -> plugin.completeBackendProxyHandlerRestart(restart(previous, replacement))); + + assertSame(previous, plugin.getBackendProxyHandler()); + verify(replacement).close(); + verify(previous).restoreAfterFailedReplacement(); + verify(previous).refreshPresenceAfterFailedReplacement(); + verify(previous, never()).close(); + } + + @Test + void predecessorCleanupFailureDoesNotRollBackPublishedReplacement() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + Config config = mock(Config.class); + when(config.getData()).thenReturn(mock(FileConfiguration.class)); + when(plugin.getConfigFile()).thenReturn(config); + doAnswer(invocation -> null).when(plugin).debug(org.mockito.ArgumentMatchers.any(Throwable.class)); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + BackendProxyHandler replacement = mock(BackendProxyHandler.class); + setBackendProxyHandler(plugin, previous); + doThrow(new IllegalStateException("listener did not stop")).when(previous).close(); + VotingPluginMain.BackendProxyRestart restart = restart(previous, replacement); + + assertDoesNotThrow(() -> plugin.completeBackendProxyHandlerRestart(restart)); + + assertSame(replacement, plugin.getBackendProxyHandler()); + assertFalse(plugin.requestBackendProxyHandlerRestartAbandonment(restart), + "published runtime state must not be treated as rollbackable"); + verify(previous, org.mockito.Mockito.timeout(1_000)).close(); + org.mockito.InOrder publication = org.mockito.Mockito.inOrder(previous, replacement); + publication.verify(previous).completeHttpHandoff(replacement); + publication.verify(replacement).activateInboundMessages(); + publication.verify(replacement).replayRedisAfterHandoffPublication(); + publication.verify(previous).close(); + } + + @Test + void disablingBungeeCoordinationDrainsActiveHttpTransportBeforeClose() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BungeeSettings settings = mock(BungeeSettings.class); + when(settings.isUseBungeecoord()).thenReturn(false); + setField(plugin, "bungeeSettings", settings); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + when(previous.getMethod()).thenReturn(BungeeMethod.HTTP); + when(previous.requiresPreparationForReplacement()).thenReturn(true); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + when(previous.commitPreparedDisable()).thenReturn(true); + setBackendProxyHandler(plugin, previous); + VotingPluginMain.BackendProxyRestart restart = plugin.prepareBackendProxyHandlerRestart(); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + 1_000_000_000L); + plugin.completeBackendProxyHandlerRestart(restart); + + verify(previous, org.mockito.Mockito.timeout(1_000)).close(); + org.mockito.InOrder disable = org.mockito.Mockito.inOrder(previous); + disable.verify(previous).preparePresenceForDisable(org.mockito.ArgumentMatchers.anyLong()); + disable.verify(previous).prepareForReplacement(org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyLong()); + disable.verify(previous).commitPreparedDisable(); + disable.verify(previous).close(); + assertNull(plugin.getBackendProxyHandler()); + } + + @Test + void disablingKeepsPreviousHandlerWhenPreparationAcceptedAnotherDelivery() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BungeeSettings settings = mock(BungeeSettings.class); + when(settings.isUseBungeecoord()).thenReturn(false); + setField(plugin, "bungeeSettings", settings); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + when(previous.requiresPreparationForReplacement()).thenReturn(true); + when(previous.prepareForReplacement(org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + when(previous.commitPreparedDisable()).thenReturn(false); + setBackendProxyHandler(plugin, previous); + VotingPluginMain.BackendProxyRestart restart = plugin.prepareBackendProxyHandlerRestart(); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + 1_000_000_000L); + assertThrows(IllegalStateException.class, () -> plugin.completeBackendProxyHandlerRestart(restart)); + plugin.abortBackendProxyHandlerRestart(restart); + + assertSame(previous, plugin.getBackendProxyHandler()); + verify(previous, never()).close(); + verify(previous).restoreAfterFailedReplacement(); + verify(previous).restorePresenceAfterFailedDisablePreparation(); + } + + @Test + void disablingMqttTransportPreparesPresenceOffTheBukkitPublicationPath() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + BungeeSettings settings = mock(BungeeSettings.class); + when(settings.isUseBungeecoord()).thenReturn(false); + setField(plugin, "bungeeSettings", settings); + BackendProxyHandler previous = mock(BackendProxyHandler.class); + when(previous.getMethod()).thenReturn(BungeeMethod.MQTT); + when(previous.commitPreparedDisable()).thenReturn(true); + setBackendProxyHandler(plugin, previous); + VotingPluginMain.BackendProxyRestart restart = plugin.prepareBackendProxyHandlerRestart(); + + plugin.validateBackendProxyHandlerRestart(restart, System.nanoTime() + 1_000_000_000L); + plugin.completeBackendProxyHandlerRestart(restart); + + verify(previous, org.mockito.Mockito.timeout(1_000)).close(); + org.mockito.InOrder disable = org.mockito.Mockito.inOrder(previous); + disable.verify(previous).preparePresenceForDisable(org.mockito.ArgumentMatchers.anyLong()); + verify(previous, never()).preparePresenceForDisable(); + verify(previous, never()).prepareForReplacement(org.mockito.ArgumentMatchers.isNull(), + org.mockito.ArgumentMatchers.anyLong()); + disable.verify(previous).commitPreparedDisable(); + disable.verify(previous).close(); + assertNull(plugin.getBackendProxyHandler()); + } + + private VotingPluginMain.BackendProxyRestart restart(BackendProxyHandler previous, + BackendProxyHandler replacement) throws Exception { + return restart(previous, replacement, false); + } + + private VotingPluginMain.BackendProxyRestart restart(BackendProxyHandler previous, + BackendProxyHandler replacement, boolean previousRequiresPreparation) throws Exception { + Constructor constructor = VotingPluginMain.BackendProxyRestart.class + .getDeclaredConstructor(BackendProxyHandler.class, BackendProxyHandler.class, boolean.class, boolean.class); + constructor.setAccessible(true); + return constructor.newInstance(previous, replacement, replacement == null, previousRequiresPreparation); + } + + private void setBackendProxyHandler(VotingPluginMain plugin, BackendProxyHandler handler) throws Exception { + setField(plugin, "backendProxyHandler", handler); + } + + private void setField(VotingPluginMain plugin, String name, Object value) throws Exception { + Field field = VotingPluginMain.class.getDeclaredField(name); + field.setAccessible(true); + field.set(plugin, value); + } + + private void setRestartField(VotingPluginMain.BackendProxyRestart restart, String name, Object value) + throws Exception { + Field field = VotingPluginMain.BackendProxyRestart.class.getDeclaredField(name); + field.setAccessible(true); + field.set(restart, value); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java index b436fadd5..718113d53 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/BackendProxyHandlerLifecycleTest.java @@ -1,31 +1,454 @@ package com.bencodez.votingplugin.backendproxy; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import java.lang.reflect.Field; +import java.net.ServerSocket; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.eclipse.paho.client.mqttv3.MqttException; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.sockets.SocketHandler; +import com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessageHandler; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; import com.bencodez.simpleapi.servercomm.mqtt.MqttHandler; +import com.bencodez.simpleapi.servercomm.mqtt.MqttServerComm; import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; import com.bencodez.simpleapi.servercomm.redis.RedisHandler; import com.bencodez.votingplugin.backendproxy.global.BackendGlobalDataSync; import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.votingplugin.proxy.VotingPluginWire; import com.bencodez.votingplugin.backendproxy.transport.MqttBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.MysqlBackendProxyTransport; +import com.bencodez.votingplugin.backendproxy.transport.PluginMessagingBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.BackendProxyTransportManager; +import com.bencodez.votingplugin.backendproxy.transport.HttpBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.RedisBackendProxyTransport; import com.bencodez.votingplugin.backendproxy.transport.SocketBackendProxyTransport; import com.bencodez.votingplugin.proxy.BungeeMethod; class BackendProxyHandlerLifecycleTest { + @Test + void globalDataWakeupMovesOffTheCallingAndPrimaryThreads() { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + handler.activateInboundMessages(); + Runnable localDispatch = mock(Runnable.class); + + handler.dispatchIncomingAfterPublication( + JsonEnvelope.builder(VotingPluginWire.SUB_BUNGEE_TIME_CHANGE).build(), localDispatch); + + verify(scheduler).runTaskAsynchronously(plugin, localDispatch); + verify(localDispatch, never()).run(); + } + + @Test + void stagedInboundWaitsForPublicationAndUsesTheBukkitScheduler() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + BackendProxyHandler handler = new BackendProxyHandler(plugin); + JsonEnvelope envelope = JsonEnvelope.builder("staged").build(); + Runnable localDispatch = mock(Runnable.class); + AtomicReference scheduled = new AtomicReference<>(); + doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + CountDownLatch started = new CountDownLatch(1); + + CompletableFuture callback = CompletableFuture.runAsync(() -> { + started.countDown(); + handler.dispatchIncomingAfterPublication(envelope, localDispatch); + }); + assertTrue(started.await(1, TimeUnit.SECONDS)); + assertFalse(callback.isDone()); + verifyNoInteractions(scheduler); + + handler.activateInboundMessages(); + callback.get(1, TimeUnit.SECONDS); + verify(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + verify(localDispatch, never()).run(); + scheduled.get().run(); + verify(localDispatch).run(); + } + + @Test + void stagedInboundRoutesThroughRestoredPredecessorOnRollback() throws Exception { + BackendProxyHandler previous = new BackendProxyHandler(null); + BackendProxyHandler replacement = new BackendProxyHandler(null); + GlobalMessageHandler previousMessages = mock(GlobalMessageHandler.class); + setField(previous, "globalMessageHandler", previousMessages); + JsonEnvelope envelope = JsonEnvelope.builder("rollback").build(); + Runnable replacementDispatch = mock(Runnable.class); + CountDownLatch started = new CountDownLatch(1); + + CompletableFuture callback = CompletableFuture.runAsync(() -> { + started.countDown(); + replacement.dispatchIncomingAfterPublication(envelope, replacementDispatch); + }); + assertTrue(started.await(1, TimeUnit.SECONDS)); + assertFalse(callback.isDone()); + + replacement.abortStagedInboundTo(previous); + callback.get(1, TimeUnit.SECONDS); + verify(previousMessages).onMessage(envelope); + verify(replacementDispatch, never()).run(); + } + + @Test + void failedPluginMessagePublicationRestoresPreviousSharedState() { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage pluginMessages = + mock(com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage.class); + GlobalMessageHandler previousHandler = mock(GlobalMessageHandler.class); + GlobalMessageHandler replacementHandler = mock(GlobalMessageHandler.class); + AtomicReference activeChannel = new AtomicReference<>("old:channel"); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(plugin.getPluginMessaging()).thenReturn(pluginMessages); + when(plugin.getBungeeChannel()).thenAnswer(ignored -> activeChannel.get()); + org.mockito.Mockito.doAnswer(invocation -> { + activeChannel.set(invocation.getArgument(0)); + return null; + }).when(plugin).registerBungeeChannels(org.mockito.ArgumentMatchers.anyString()); + + when(settings.getPluginMessagingChannel()).thenReturn("old:channel"); + PluginMessagingBackendProxyTransport previous = new PluginMessagingBackendProxyTransport(plugin); + previous.start(previousHandler); + previous.activateAfterPublication(); + + when(settings.getPluginMessagingChannel()).thenReturn("new:channel"); + PluginMessagingBackendProxyTransport replacement = new PluginMessagingBackendProxyTransport(plugin); + replacement.start(replacementHandler); + replacement.activateAfterPublication(); + replacement.close(); + BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin); + try { + Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport"); + transportField.setAccessible(true); + transportField.set(manager, previous); + manager.restoreAfterFailedReplacement(); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + + assertEquals("old:channel", activeChannel.get()); + verify(plugin, times(2)).activateBackendPluginMessageHandler(previousHandler); + verify(plugin).activateBackendPluginMessageHandler(replacementHandler); + verify(plugin).deactivateBackendPluginMessageHandler(replacementHandler); + } + + @Test + void stagedPluginMessageTransportDefersRelaySwapUntilPublication() { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage pluginMessages = + mock(com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage.class); + GlobalMessageHandler replacement = mock(GlobalMessageHandler.class); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(plugin.getPluginMessaging()).thenReturn(pluginMessages); + when(settings.getPluginMessagingChannel()).thenReturn("votingplugin:main"); + + PluginMessagingBackendProxyTransport transport = new PluginMessagingBackendProxyTransport(plugin); + transport.start(replacement); + verify(plugin, never()).activateBackendPluginMessageHandler(replacement); + verify(plugin, never()).registerBungeeChannels(org.mockito.ArgumentMatchers.anyString()); + verifyNoInteractions(pluginMessages); + transport.close(); + verify(plugin, never()).deactivateBackendPluginMessageHandler(replacement); + verifyNoInteractions(pluginMessages); + + transport = new PluginMessagingBackendProxyTransport(plugin); + transport.start(replacement); + transport.activateAfterPublication(); + transport.activateAfterPublication(); + verify(plugin, times(1)).registerBungeeChannels("votingplugin:main"); + verify(pluginMessages).setEncryptionHandler(null); + verify(pluginMessages).setDebug(false); + verify(plugin, times(1)).activateBackendPluginMessageHandler(replacement); + + transport.close(); + verify(plugin).deactivateBackendPluginMessageHandler(replacement); + } + + @Test + void movingPluginMessageRelayDetachesItFromThePreviousPluginMessage() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class, + CALLS_REAL_METHODS); + com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage first = + mock(com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage.class); + com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage second = + mock(com.bencodez.simpleapi.servercomm.pluginmessage.PluginMessage.class); + java.util.ArrayList firstHandlers = new java.util.ArrayList<>(); + java.util.ArrayList secondHandlers = new java.util.ArrayList<>(); + GlobalMessageHandler firstTarget = mock(GlobalMessageHandler.class); + GlobalMessageHandler secondTarget = mock(GlobalMessageHandler.class); + AtomicReference current = + new AtomicReference<>(first); + when(plugin.getPluginMessaging()).thenAnswer(ignored -> current.get()); + when(first.getPluginMessages()).thenReturn(firstHandlers); + when(second.getPluginMessages()).thenReturn(secondHandlers); + org.mockito.Mockito.doAnswer(invocation -> firstHandlers.add(invocation.getArgument(0))).when(first) + .add(org.mockito.ArgumentMatchers.any(PluginMessageHandler.class)); + org.mockito.Mockito.doAnswer(invocation -> secondHandlers.add(invocation.getArgument(0))).when(second) + .add(org.mockito.ArgumentMatchers.any(PluginMessageHandler.class)); + Field targetField = com.bencodez.votingplugin.VotingPluginMain.class + .getDeclaredField("backendPluginMessageTarget"); + targetField.setAccessible(true); + targetField.set(plugin, new AtomicReference()); + + plugin.activateBackendPluginMessageHandler(firstTarget); + current.set(second); + plugin.activateBackendPluginMessageHandler(secondTarget); + + assertTrue(firstHandlers.isEmpty()); + assertEquals(1, secondHandlers.size()); + secondHandlers.get(0).onReceive(null); + verify(firstTarget, never()).onMessage(null); + verify(secondTarget).onMessage(null); + } + + @Test + void failedPresenceActivationDoesNotAnnounceReplacementGeneration() { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getServer()).thenReturn("lobby"); + when(plugin.getTimer()).thenReturn(timer); + when(timer.scheduleAtFixedRate(org.mockito.ArgumentMatchers.any(Runnable.class), + org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any())).thenThrow(new java.util.concurrent.RejectedExecutionException()); + + BackendPresenceManager presence = new BackendPresenceManager(plugin, BungeeMethod.HTTP, messages); + assertThrows(java.util.concurrent.RejectedExecutionException.class, presence::start); + + verifyNoInteractions(messages); + } + + @Test + void preparedDisablePropagatesRejectedStoppedPresence() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + BackendPresenceManager presence = new BackendPresenceManager(plugin, BungeeMethod.HTTP, messages); + setField(presence, "reporting", true); + setField(presence, "server", "backend-1"); + setField(presence, "incarnationId", java.util.UUID.randomUUID()); + setField(presence, "startedAt", 1L); + @SuppressWarnings("unchecked") + java.util.concurrent.ConcurrentHashMap sessions = + (java.util.concurrent.ConcurrentHashMap) getField( + presence, "playerSessions"); + sessions.put("player", com.bencodez.votingplugin.backendproxy.presence.BackendPlayerPresenceSession + .create("Player", "550e8400-e29b-41d4-a716-446655440000")); + setField(presence, "lastResyncRequestId", java.util.UUID.randomUUID()); + setField(presence, "lastResyncRequestAtNanos", 42L); + setField(presence, "lastSnapshotRequestId", java.util.UUID.randomUUID()); + setField(presence, "lastSnapshotRequestAtNanos", 84L); + doThrow(new IllegalStateException("outbound queue full")).when(messages).sendMessage(any()); + + assertThrows(IllegalStateException.class, presence::stopForDisable); + assertFalse(presence.isReporting()); + assertNull(getField(presence, "incarnationId")); + assertEquals(0, presence.getTrackedSessionCount()); + assertNull(getField(presence, "lastResyncRequestId")); + assertEquals(0L, getField(presence, "lastResyncRequestAtNanos")); + assertNull(getField(presence, "lastSnapshotRequestId")); + assertEquals(0L, getField(presence, "lastSnapshotRequestAtNanos")); + } + + @Test + void delayedPreparedDisableCannotStopANewerPresenceGeneration() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + org.bukkit.Server server = mock(org.bukkit.Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + when(plugin.getServer()).thenReturn(server); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(server.isPrimaryThread()).thenReturn(false); + AtomicReference queued = new AtomicReference<>(); + org.mockito.Mockito.doAnswer(invocation -> { + queued.set(invocation.getArgument(1)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + + BackendPresenceManager presence = new BackendPresenceManager(plugin, BungeeMethod.PLUGINMESSAGING, messages); + java.util.UUID firstIncarnation = java.util.UUID.randomUUID(); + setField(presence, "lifecycleGeneration", 1L); + setField(presence, "reporting", true); + setField(presence, "server", "backend-1"); + setField(presence, "incarnationId", firstIncarnation); + setField(presence, "startedAt", 1L); + + assertThrows(IllegalStateException.class, + () -> presence.stopForDisable(System.nanoTime() - 1L)); + assertNotNull(queued.get()); + + java.util.UUID replacementIncarnation = java.util.UUID.randomUUID(); + setField(presence, "lifecycleGeneration", 2L); + setField(presence, "reporting", true); + setField(presence, "server", "backend-1"); + setField(presence, "incarnationId", replacementIncarnation); + setField(presence, "startedAt", 2L); + + queued.get().run(); + + assertTrue(presence.isReporting()); + assertEquals(replacementIncarnation, getField(presence, "incarnationId")); + verifyNoInteractions(messages); + } + + @Test + void preparedDisableSchedulesStoppedPresenceOnTheBukkitThread() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + org.bukkit.Server server = mock(org.bukkit.Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + when(plugin.getServer()).thenReturn(server); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(server.isPrimaryThread()).thenReturn(false); + java.util.concurrent.atomic.AtomicBoolean onScheduledTask = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(invocation -> { + onScheduledTask.set(true); + try { + Runnable task = invocation.getArgument(1); + task.run(); + } finally { + onScheduledTask.set(false); + } + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + org.mockito.Mockito.doAnswer(invocation -> { + assertTrue(onScheduledTask.get(), "stopped presence must be sent by the scheduled Bukkit task"); + return null; + }).when(messages).sendMessage(any()); + + BackendPresenceManager presence = new BackendPresenceManager(plugin, BungeeMethod.PLUGINMESSAGING, messages); + setField(presence, "reporting", true); + setField(presence, "server", "backend-1"); + setField(presence, "incarnationId", java.util.UUID.randomUUID()); + setField(presence, "startedAt", 1L); + + presence.stopForDisable(); + + verify(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + verify(messages).sendMessage(any()); + assertFalse(presence.isReporting()); + } + + @Test + void preparedDisableKeepsHttpStoppedPresenceOffTheBukkitThread() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + org.bukkit.Server server = mock(org.bukkit.Server.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + when(plugin.getServer()).thenReturn(server); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(server.isPrimaryThread()).thenReturn(false); + + BackendPresenceManager presence = new BackendPresenceManager(plugin, BungeeMethod.HTTP, messages); + setField(presence, "reporting", true); + setField(presence, "server", "backend-1"); + setField(presence, "incarnationId", java.util.UUID.randomUUID()); + setField(presence, "startedAt", 1L); + + presence.stopForDisable(); + + verify(scheduler, never()).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + verify(messages).sendMessage(any()); + assertFalse(presence.isReporting()); + } + + @Test + void failedPreparedDisableCanRestartPresenceDuringRollback() throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + BackendPresenceManager presence = mock(BackendPresenceManager.class); + setField(handler, "presenceManager", presence); + setField(handler, "presenceReportingActivated", true); + doThrow(new IllegalStateException("outbound queue full")).when(presence).stopForDisable(); + + assertThrows(IllegalStateException.class, handler::preparePresenceForDisable); + handler.restorePresenceAfterFailedDisablePreparation(); + + org.mockito.InOrder rollback = inOrder(presence); + rollback.verify(presence).stopForDisable(); + rollback.verify(presence).start(); + } + + @Test + void stagedPresenceStartsOnlyAtExplicitPublication() throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + BackendPresenceManager presence = mock(BackendPresenceManager.class); + Field field = BackendProxyHandler.class.getDeclaredField("presenceManager"); + field.setAccessible(true); + field.set(handler, presence); + + verifyNoInteractions(presence); + handler.activatePresenceReporting(); + handler.activatePresenceReporting(); + + verify(presence, times(1)).start(); + } + + @Test + void failedPresenceStartKeepsTransportBehindPublicationBarrier() throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + BackendPresenceManager presence = mock(BackendPresenceManager.class); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException()).when(presence).start(); + Field presenceField = BackendProxyHandler.class.getDeclaredField("presenceManager"); + presenceField.setAccessible(true); + presenceField.set(handler, presence); + + Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager"); + managerField.setAccessible(true); + BackendProxyTransportManager manager = (BackendProxyTransportManager) managerField.get(handler); + BackendProxyTransport transport = mock(BackendProxyTransport.class); + Field transportField = BackendProxyTransportManager.class.getDeclaredField("transport"); + transportField.setAccessible(true); + transportField.set(manager, transport); + + assertThrows(java.util.concurrent.RejectedExecutionException.class, handler::activatePresenceReporting); + + verify(presence).start(); + verify(transport, never()).activateAfterPublication(); + } @Test void sharesVoteDeduplicationAcrossHandlerReplacement() { @@ -69,6 +492,72 @@ void releasesSocketListenerBeforeSamePortReplacement() throws Exception { assertNull(handler.getSocketHandler()); } + @Test + void ordinarySocketCloseToleratesListenerTimeoutButReplacementPreparationRejectsIt() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + SocketHandler ordinarySocket = mock(SocketHandler.class); + com.bencodez.simpleapi.servercomm.sockets.SocketServer ordinaryServer = + mock(com.bencodez.simpleapi.servercomm.sockets.SocketServer.class); + when(ordinarySocket.getServer()).thenReturn(ordinaryServer); + when(ordinaryServer.isAlive()).thenReturn(true); + SocketBackendProxyTransport ordinary = new SocketBackendProxyTransport(plugin); + setField(ordinary, "socketHandler", ordinarySocket); + + assertDoesNotThrow(ordinary::close); + verify(ordinarySocket).closeConnection(); + verify(ordinaryServer).close(); + + SocketHandler replacementSocket = mock(SocketHandler.class); + com.bencodez.simpleapi.servercomm.sockets.SocketServer replacementServer = + mock(com.bencodez.simpleapi.servercomm.sockets.SocketServer.class); + when(replacementSocket.getServer()).thenReturn(replacementServer); + when(replacementServer.isAlive()).thenReturn(true); + SocketBackendProxyTransport replacement = new SocketBackendProxyTransport(plugin); + setField(replacement, "socketHandler", replacementSocket); + + assertThrows(IllegalStateException.class, replacement::prepareForReplacement); + verify(replacementSocket).closeConnection(); + verify(replacementServer).close(); + } + + @Test + void rejectsSamePortSocketReplacementUntilThePreviousListenerIsPrepared(@TempDir Path dataFolder) + throws Exception { + int port; + try (ServerSocket allocation = new ServerSocket(0)) { + port = allocation.getLocalPort(); + } + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(plugin.getName()).thenReturn("socket-lifecycle-test"); + when(plugin.getDataFolder()).thenReturn(dataFolder.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getLogger("socket-lifecycle-test")); + when(settings.getBungeeServerHost()).thenReturn("127.0.0.1"); + when(settings.getBungeeServerPort()).thenReturn(1); + when(settings.getSpigotServerHost()).thenReturn("127.0.0.1"); + when(settings.getSpigotServerPort()).thenReturn(port); + + SocketBackendProxyTransport previous = new SocketBackendProxyTransport(plugin); + SocketBackendProxyTransport replacement = new SocketBackendProxyTransport(plugin); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + try { + previous.start(messages); + assertThrows(IllegalStateException.class, () -> replacement.start(messages)); + assertNull(replacement.getClientHandler()); + + previous.prepareForReplacement(); + assertDoesNotThrow(() -> replacement.start(messages)); + replacement.close(); + assertDoesNotThrow(previous::restoreAfterFailedReplacement); + assertNotNull(previous.getSocketHandler()); + } finally { + replacement.close(); + previous.close(); + } + } + @Test void keepsRedisSubscriberUntilReplacementIsReady() throws Exception { BackendProxyHandler handler = new BackendProxyHandler(null); @@ -107,6 +596,419 @@ void releasesMqttSubscriberBeforeSameMethodReplacement() throws Exception { assertNull(handler.getMqttHandler()); } + @Test + void preparesSameSocketReplacementByRetiringTheExistingListener() throws Exception { + BackendProxyHandler handler = handlerWithTransport(BungeeMethod.SOCKETS); + SocketHandler socket = mock(SocketHandler.class); + SocketBackendProxyTransport transport = new SocketBackendProxyTransport(null); + setField(transport, "socketHandler", socket); + setTransport(handler, transport); + + assertTrue(handler.prepareForReplacement(BungeeMethod.SOCKETS)); + + assertNull(handler.getSocketHandler()); + verify(socket).closeConnection(); + } + + @Test + void preparesSameMqttReplacementByRetiringTheExistingClient() throws Exception { + BackendProxyHandler handler = handlerWithTransport(BungeeMethod.MQTT); + MqttHandler mqtt = mock(MqttHandler.class); + MqttBackendProxyTransport transport = new MqttBackendProxyTransport(null); + setField(transport, "mqttHandler", mqtt); + setTransport(handler, transport); + + assertTrue(handler.prepareForReplacement(BungeeMethod.MQTT)); + + assertNull(handler.getMqttHandler()); + verify(mqtt).disconnect(); + } + + @Test + void restoresPreparedMqttClientWhenReplacementValidationIsAbandoned() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(null); + MqttBackendProxyTransport mqtt = mock(MqttBackendProxyTransport.class); + setField(manager, "preparedTransport", mqtt); + + manager.restoreAfterFailedReplacement(); + + assertSame(mqtt, transport(manager)); + verify(mqtt).restoreAfterFailedReplacement(); + } + + @Test + void failedMqttDisconnectKeepsAStillConnectedPredecessor() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(null); + MqttBackendProxyTransport mqtt = mock(MqttBackendProxyTransport.class); + doThrow(new IllegalStateException("disconnect failed")).when(mqtt).prepareForReplacement(); + when(mqtt.isConnected()).thenReturn(true); + setField(manager, "transport", mqtt); + + assertThrows(IllegalStateException.class, manager::prepareForReplacement); + + assertSame(mqtt, transport(manager)); + verify(mqtt, never()).restoreAfterFailedReplacement(); + } + + @Test + void disconnectsPartialMqttClientWhenSubscriptionFails() throws Exception { + MqttHandler partial = mock(MqttHandler.class); + doThrow(new MqttException(0)).when(partial).subscribeEnvelopes(any(), any()); + MqttBackendProxyTransport transport = new MqttBackendProxyTransport(null) { + @Override + protected MqttServerComm createMqttServerComm() { + return mock(MqttServerComm.class); + } + + @Override + protected MqttHandler createMqttHandler(MqttServerComm server) { + return partial; + } + }; + setField(transport, "messageHandler", mock(GlobalMessageHandler.class)); + setField(transport, "clientId", "backend-1"); + setField(transport, "brokerUrl", "tcp://broker.invalid:1883"); + setField(transport, "subscriptionTopic", "votingplugin/servers/backend-1"); + + assertThrows(IllegalStateException.class, transport::restoreAfterFailedReplacement); + + assertNull(transport.getMqttHandler()); + verify(partial).disconnect(); + } + + @Test + void failedSameMysqlReplacementLeavesExistingTransportAvailable() throws Exception { + BackendProxyHandler handler = handlerWithTransport(BungeeMethod.MYSQL); + MySqlMessenger messenger = mock(MySqlMessenger.class); + MysqlBackendProxyTransport transport = new MysqlBackendProxyTransport(null); + setField(transport, "messenger", messenger); + setTransport(handler, transport); + + assertFalse(handler.prepareForReplacement(BungeeMethod.MYSQL)); + + assertSame(messenger, handler.getBackendMysqlMessenger()); + verifyNoInteractions(messenger); + } + + @Test + void preparesHttpDeliveryQueueWhenSwitchingToAnotherMethod() throws Exception { + BackendProxyHandler handler = handlerWithTransport(BungeeMethod.HTTP); + HttpBackendProxyTransport transport = mock(HttpBackendProxyTransport.class); + setTransport(handler, transport); + + assertTrue(handler.prepareForReplacement(BungeeMethod.REDIS)); + + verify(transport).prepareForReplacement(); + } + + @Test + void redisSendKeepsTheChannelCapturedByTheActiveTransport() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = + mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getRedisPrefix()).thenReturn("new:"); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin); + RedisHandler redis = mock(RedisHandler.class); + setField(transport, "redisHandler", redis); + setField(transport, "publishChannel", "old:VotingPlugin"); + + transport.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("vote").build()); + + verify(redis).publishEnvelope(eq("old:VotingPlugin"), any()); + } + + @Test + void mqttSendKeepsTheTopicCapturedByTheActiveTransport() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = + mock(com.bencodez.votingplugin.VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getMqttPrefix()).thenReturn("new/"); + MqttBackendProxyTransport transport = new MqttBackendProxyTransport(plugin); + MqttHandler mqtt = mock(MqttHandler.class); + setField(transport, "mqttHandler", mqtt); + setField(transport, "publishTopic", "old/votingplugin/servers/proxy"); + + assertTrue(transport.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("vote").build())); + + verify(mqtt).publishEnvelope(eq("old/votingplugin/servers/proxy"), any()); + } + + @Test + void mqttAndMysqlReportRejectedHandoffDeliveries() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + MqttBackendProxyTransport mqttTransport = new MqttBackendProxyTransport(plugin); + MqttHandler mqtt = mock(MqttHandler.class); + setField(mqttTransport, "mqttHandler", mqtt); + setField(mqttTransport, "publishTopic", "votingplugin/servers/proxy"); + doThrow(new IllegalStateException("publish failed")).when(mqtt).publishEnvelope(any(), any()); + JsonEnvelope mqttEnvelope = JsonEnvelope.builder("mqtt").build(); + + assertFalse(mqttTransport.send(mqttEnvelope)); + + MysqlBackendProxyTransport mysqlTransport = new MysqlBackendProxyTransport(plugin); + MySqlMessenger mysql = mock(MySqlMessenger.class); + setField(mysqlTransport, "messenger", mysql); + doThrow(new java.sql.SQLException("write failed")).when(mysql).sendToProxy(any()); + + assertFalse(mysqlTransport.send(JsonEnvelope.builder("mysql").build())); + } + + @Test + void asyncHandoffRetainsARejectedEntryUntilAcceptance() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + BackendProxyTransportManager source = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + BackendProxyTransport sourceTransport = mock(BackendProxyTransport.class); + BackendProxyTransport replacementTransport = mock(BackendProxyTransport.class); + JsonEnvelope envelope = JsonEnvelope.builder("retry").build(); + when(replacementTransport.send(envelope)).thenReturn(false, true); + setField(source, "transport", sourceTransport); + setField(replacement, "transport", replacementTransport); + + replacement.beginPreparedTransportHandoff(); + source.prepareForReplacement(); + source.send(envelope); + source.completePreparedTransportHandoff(replacement); + + verify(replacementTransport, org.mockito.Mockito.timeout(1500).times(2)).send(envelope); + replacement.awaitAsyncHandoff(System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + assertFalse(replacement.hasPendingAsyncHandoff()); + } + + @Test + void forwardsMessagesBufferedDuringPreparedHttpReplacement() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + BackendProxyTransport previousTransport = mock(BackendProxyTransport.class); + BackendProxyTransport replacementTransport = mock(BackendProxyTransport.class); + when(replacementTransport.send(any())).thenReturn(true); + setField(previous, "transport", previousTransport); + setField(replacement, "transport", replacementTransport); + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope duringValidation = + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("during-validation").build(); + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope afterPublication = + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("after-publication").build(); + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope replacementStarted = + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("replacement-started").build(); + + replacement.beginPreparedTransportHandoff(); + previous.prepareForReplacement(); + previous.send(duringValidation); + replacement.send(replacementStarted); + verifyNoInteractions(replacementTransport); + + previous.completePreparedTransportHandoff(replacement); + previous.send(afterPublication); + + org.mockito.InOrder order = inOrder(replacementTransport); + order.verify(replacementTransport, org.mockito.Mockito.timeout(1000)).send(duringValidation); + order.verify(replacementTransport).send(replacementStarted); + order.verify(replacementTransport).send(afterPublication); + } + + @Test + void crossTransportPreparedHandoffDoesNotPerformNetworkSendOnCallerThread() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + BackendProxyTransport previousTransport = mock(BackendProxyTransport.class); + BackendProxyTransport replacementTransport = mock(BackendProxyTransport.class); + setField(previous, "transport", previousTransport); + setField(replacement, "transport", replacementTransport); + java.util.concurrent.CountDownLatch sendStarted = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch releaseSend = new java.util.concurrent.CountDownLatch(1); + doAnswer(invocation -> { + sendStarted.countDown(); + releaseSend.await(2, java.util.concurrent.TimeUnit.SECONDS); + return true; + }).when(replacementTransport).send(any()); + + replacement.beginPreparedTransportHandoff(); + previous.prepareForReplacement(); + previous.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("buffered").build()); + org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(java.time.Duration.ofSeconds(1), + () -> previous.completePreparedTransportHandoff(replacement)); + + org.junit.jupiter.api.Assertions.assertTrue(sendStarted.await(1, java.util.concurrent.TimeUnit.SECONDS)); + releaseSend.countDown(); + verify(replacementTransport, org.mockito.Mockito.timeout(1000)).send(any()); + } + + @Test + void preparedDisableRefusesToDiscardMessagesAcceptedDuringPreparation() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin); + HttpBackendProxyTransport transport = mock(HttpBackendProxyTransport.class); + setField(manager, "transport", transport); + + manager.prepareForReplacement(); + manager.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("during-disable").build()); + + assertFalse(manager.commitPreparedDisable()); + verify(transport, never()).close(); + } + + @Test + void preparedDisableRejectsOrdinarySendsButDeliversOnlyOneFinalStoppedPresence() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(Logger.getLogger(getClass().getName())); + BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin); + BackendProxyTransport transport = mock(BackendProxyTransport.class); + setField(manager, "transport", transport); + JsonEnvelope ordinary = JsonEnvelope.builder("ordinary").build(); + JsonEnvelope stopped = com.bencodez.votingplugin.proxy.VotingPluginWire.backendStopped("backend-1"); + when(transport.send(stopped)).thenReturn(true); + + manager.beginPreparedDisable(); + manager.send(ordinary); + manager.send(stopped); + manager.send(stopped); + + verify(transport, never()).send(ordinary); + verify(transport, times(1)).send(stopped); + } + + @Test + void preparedHttpDisableDeliversTheFinalStoppedPresenceBeforeClosingForReplacement() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin); + HttpBackendProxyTransport transport = mock(HttpBackendProxyTransport.class); + setField(manager, "transport", transport); + JsonEnvelope stopped = com.bencodez.votingplugin.proxy.VotingPluginWire.backendStopped("backend-1"); + when(transport.send(stopped)).thenReturn(true); + + manager.beginPreparedDisable(); + manager.send(stopped); + manager.prepareForReplacement(); + assertTrue(manager.commitPreparedDisable()); + + org.mockito.InOrder disable = inOrder(transport); + disable.verify(transport).send(stopped); + disable.verify(transport).prepareForReplacement(); + } + + @Test + void laterReplacementWaitsForAnEarlierAsyncHandoff() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + BackendProxyTransportManager source = new BackendProxyTransportManager(plugin); + BackendProxyHandler target = handlerWithTransport(BungeeMethod.REDIS); + Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager"); + managerField.setAccessible(true); + BackendProxyTransportManager targetManager = (BackendProxyTransportManager) managerField.get(target); + BackendProxyTransport sourceTransport = mock(BackendProxyTransport.class); + BackendProxyTransport targetTransport = mock(BackendProxyTransport.class); + setField(source, "transport", sourceTransport); + setField(targetManager, "transport", targetTransport); + java.util.concurrent.CountDownLatch sendStarted = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch releaseSend = new java.util.concurrent.CountDownLatch(1); + doAnswer(invocation -> { + sendStarted.countDown(); + releaseSend.await(2, java.util.concurrent.TimeUnit.SECONDS); + return true; + }).when(targetTransport).send(any()); + + targetManager.beginPreparedTransportHandoff(); + source.prepareForReplacement(); + source.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("buffered").build()); + source.completePreparedTransportHandoff(targetManager); + assertTrue(sendStarted.await(1, java.util.concurrent.TimeUnit.SECONDS)); + assertTrue(target.requiresPreparationForReplacement()); + java.util.concurrent.CompletableFuture preparation = java.util.concurrent.CompletableFuture.supplyAsync( + () -> target.prepareForReplacement(BungeeMethod.MQTT, + System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2))); + try { + Thread.sleep(50); + assertFalse(preparation.isDone(), "replacement must wait while an admitted send is in flight"); + } finally { + releaseSend.countDown(); + } + assertTrue(preparation.get(1, java.util.concurrent.TimeUnit.SECONDS)); + assertFalse(targetManager.hasPendingAsyncHandoff()); + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope afterPreparation = + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("after-preparation").build(); + targetManager.send(afterPreparation); + BackendProxyTransportManager next = new BackendProxyTransportManager(plugin); + BackendProxyTransport nextTransport = mock(BackendProxyTransport.class); + when(nextTransport.send(any())).thenReturn(true); + setField(next, "transport", nextTransport); + next.beginPreparedTransportHandoff(); + targetManager.completePreparedTransportHandoff(next); + verify(nextTransport, org.mockito.Mockito.timeout(1000)).send(afterPreparation); + } + + @Test + void pluginMessageHandoffRunsThroughTheBukkitScheduler() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + java.util.concurrent.atomic.AtomicReference scheduled = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + BackendProxyTransportManager source = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager target = new BackendProxyTransportManager(plugin); + BackendProxyTransport sourceTransport = mock(BackendProxyTransport.class); + PluginMessagingBackendProxyTransport targetTransport = mock(PluginMessagingBackendProxyTransport.class); + when(targetTransport.send(any())).thenReturn(true); + setField(source, "transport", sourceTransport); + setField(target, "transport", targetTransport); + + target.beginPreparedTransportHandoff(); + source.prepareForReplacement(); + source.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("buffered").build()); + source.completePreparedTransportHandoff(target); + + verify(targetTransport, never()).send(any()); + assertTrue(scheduled.get() != null); + scheduled.get().run(); + verify(targetTransport).send(any()); + assertFalse(target.hasPendingAsyncHandoff()); + + target.prepareAsyncHandoffForReplacement( + System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(1)); + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope afterPreparation = + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("after-preparation").build(); + target.send(afterPreparation); + BackendProxyTransportManager next = new BackendProxyTransportManager(plugin); + BackendProxyTransport nextTransport = mock(BackendProxyTransport.class); + when(nextTransport.send(any())).thenReturn(true); + setField(next, "transport", nextTransport); + next.beginPreparedTransportHandoff(); + target.completePreparedTransportHandoff(next); + verify(nextTransport, org.mockito.Mockito.timeout(1000)).send(afterPreparation); + } + + @Test + void stalePluginMessageHandoffCallbackClearsItsScheduledFlag() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + java.util.concurrent.atomic.AtomicReference scheduled = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + BackendProxyTransportManager manager = new BackendProxyTransportManager(plugin); + PluginMessagingBackendProxyTransport pluginMessages = mock(PluginMessagingBackendProxyTransport.class); + setField(manager, "transport", pluginMessages); + + manager.send(com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.builder("queued").build()); + assertNotNull(scheduled.get()); + assertTrue((boolean) getField(manager, "pluginMessageHandoffScheduled")); + setField(manager, "transport", null); + + scheduled.get().run(); + + assertFalse((boolean) getField(manager, "pluginMessageHandoffScheduled")); + } + @Test void releasesMysqlSubscriberBeforeSameMethodReplacement() throws Exception { MysqlBackendProxyTransport handler = new MysqlBackendProxyTransport(null); @@ -121,6 +1023,137 @@ void releasesMysqlSubscriberBeforeSameMethodReplacement() throws Exception { assertNull(handler.getMessenger()); } + @Test + void promotesRedisReplacementAfterOldListenerIsFencedEvenWhenRetirementFails() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + java.util.logging.Logger logger = mock(java.util.logging.Logger.class); + when(plugin.getLogger()).thenReturn(logger); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + RedisBackendProxyTransport oldTransport = mock(RedisBackendProxyTransport.class); + RedisBackendProxyTransport newTransport = mock(RedisBackendProxyTransport.class); + setField(previous, "transport", oldTransport); + setField(replacement, "transport", newTransport); + doThrow(new IllegalStateException("listener did not stop")).when(oldTransport).closeForHandoff(); + doThrow(new IllegalStateException("connection still closing")).doNothing().when(oldTransport).close(); + + previous.completeRedisHandoff(replacement); + + org.mockito.InOrder order = inOrder(newTransport, oldTransport); + order.verify(oldTransport).closeForHandoff(); + order.verify(newTransport).activateAfterHandoff(); + assertSame(newTransport, transport(replacement)); + assertNull(transport(previous)); + + assertDoesNotThrow(previous::close); + assertDoesNotThrow(previous::close); + + verify(oldTransport, timeout(1_000).times(2)).close(); + } + + @Test + void sameRedisHandoffBuffersOutboundSendsUntilPublicationAdmission() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + RedisBackendProxyTransport oldTransport = mock(RedisBackendProxyTransport.class); + RedisBackendProxyTransport newTransport = mock(RedisBackendProxyTransport.class); + JsonEnvelope envelope = JsonEnvelope.builder("outbound-during-redis-handoff").build(); + when(newTransport.send(envelope)).thenReturn(true); + setField(previous, "transport", oldTransport); + setField(replacement, "transport", newTransport); + + // Validation fences the staged replacement before the old Redis listener is + // retired. Its send FIFO must be admitted only after publication succeeds. + replacement.beginPreparedTransportHandoff(); + previous.completeRedisHandoff(replacement); + previous.send(envelope); + + verify(oldTransport, never()).send(envelope); + verify(newTransport, never()).send(envelope); + + previous.completePreparedTransportHandoff(replacement); + + verify(newTransport, timeout(1_000).times(1)).send(envelope); + } + + @Test + void sameRedisHandoffRollbackDrainsBufferedOutboundSendsThroughRestoredListener() throws Exception { + com.bencodez.votingplugin.VotingPluginMain plugin = mock(com.bencodez.votingplugin.VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + RedisBackendProxyTransport oldTransport = mock(RedisBackendProxyTransport.class); + RedisBackendProxyTransport newTransport = mock(RedisBackendProxyTransport.class); + JsonEnvelope envelope = JsonEnvelope.builder("outbound-redis-rollback").build(); + when(oldTransport.send(envelope)).thenReturn(true); + setField(previous, "transport", oldTransport); + setField(replacement, "transport", newTransport); + + replacement.beginPreparedTransportHandoff(); + previous.completeRedisHandoff(replacement); + previous.send(envelope); + previous.restoreAfterFailedReplacement(replacement); + + verify(newTransport, never()).send(envelope); + verify(oldTransport, timeout(1_000).times(1)).send(envelope); + } + + @Test + void doesNotPromoteRedisReplacementWhenOldCallbacksDoNotQuiesce() throws Exception { + BackendProxyTransportManager previous = new BackendProxyTransportManager(null); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(null); + RedisBackendProxyTransport oldTransport = mock(RedisBackendProxyTransport.class); + RedisBackendProxyTransport newTransport = mock(RedisBackendProxyTransport.class); + setField(previous, "transport", oldTransport); + setField(replacement, "transport", newTransport); + doThrow(new RedisBackendProxyTransport.HandoffQuiescenceException("busy")) + .when(oldTransport).closeForHandoff(); + + assertThrows(RedisBackendProxyTransport.HandoffQuiescenceException.class, + () -> previous.completeRedisHandoff(replacement)); + + verify(newTransport, never()).activateAfterHandoff(); + assertSame(oldTransport, transport(previous)); + assertSame(newTransport, transport(replacement)); + } + + @Test + void restoresOldRedisSubscriberWhenStandbyActivationFails() throws Exception { + BackendProxyTransportManager previous = new BackendProxyTransportManager(null); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(null); + RedisBackendProxyTransport oldTransport = mock(RedisBackendProxyTransport.class); + RedisBackendProxyTransport newTransport = mock(RedisBackendProxyTransport.class); + setField(previous, "transport", oldTransport); + setField(replacement, "transport", newTransport); + doThrow(new IllegalStateException("handoff buffer overflowed")).when(newTransport).activateAfterHandoff(); + + assertThrows(IllegalStateException.class, () -> previous.completeRedisHandoff(replacement)); + + org.mockito.InOrder order = inOrder(oldTransport, newTransport); + order.verify(oldTransport).closeForHandoff(); + order.verify(newTransport).activateAfterHandoff(); + order.verify(oldTransport).restoreAfterFailedHandoff(java.util.Collections.emptyList()); + assertSame(oldTransport, transport(previous)); + assertSame(newTransport, transport(replacement)); + } + + @Test + void redisRollbackForcesAFreshPresenceGeneration() throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager presence = + mock(com.bencodez.votingplugin.backendproxy.presence.BackendPresenceManager.class); + setField(handler, "presenceManager", presence); + setField(handler, "presenceReportingActivated", true); + + handler.refreshPresenceAfterFailedReplacement(); + + org.mockito.InOrder order = inOrder(presence); + order.verify(presence).stop(); + order.verify(presence).start(); + } + @Test void stopsGlobalDataTimerWhenHandlerIsReplaced() throws Exception { BackendGlobalDataSync handler = new BackendGlobalDataSync(null, null); @@ -134,4 +1167,52 @@ void stopsGlobalDataTimerWhenHandlerIsReplaced() throws Exception { verify(timer).shutdownNow(); assertNull(handler.getTimer()); } + + private BackendProxyHandler handlerWithTransport(BungeeMethod method) throws Exception { + BackendProxyHandler handler = new BackendProxyHandler(null); + setField(handler, "method", method); + return handler; + } + + private void setTransport(BackendProxyHandler handler, BackendProxyTransport transport) throws Exception { + Field managerField = BackendProxyHandler.class.getDeclaredField("transportManager"); + managerField.setAccessible(true); + BackendProxyTransportManager manager = (BackendProxyTransportManager) managerField.get(handler); + setField(manager, "transport", transport); + } + + private BackendProxyTransport transport(BackendProxyTransportManager manager) throws Exception { + Field field = BackendProxyTransportManager.class.getDeclaredField("transport"); + field.setAccessible(true); + return (BackendProxyTransport) field.get(manager); + } + + private void setField(Object target, String name, Object value) throws Exception { + Class type = target.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } + + private Object getField(Object target, String name) throws Exception { + Class type = target.getClass(); + while (type != null) { + try { + Field field = type.getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new NoSuchFieldException(name); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java new file mode 100644 index 000000000..800b14c10 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/global/BackendGlobalDataSyncTest.java @@ -0,0 +1,156 @@ +package com.bencodez.votingplugin.backendproxy.global; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalDataHandler; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.sql.data.DataValueBoolean; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.BungeeSettings; + +class BackendGlobalDataSyncTest { + + @Test + void bungeeForceUpdateStateMutationRunsOnTheBukkitScheduler() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings bungeeSettings = mock(BungeeSettings.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + GlobalDataHandler globalDataHandler = mock(GlobalDataHandler.class); + UserDataManager dataManager = mock(UserDataManager.class); + UserManager userManager = mock(UserManager.class); + AtomicReference scheduled = new AtomicReference<>(); + AtomicReference asyncWrite = new AtomicReference<>(); + AtomicInteger schedulerRuns = new AtomicInteger(); + + when(plugin.getBungeeSettings()).thenReturn(bungeeSettings); + when(bungeeSettings.getServer()).thenReturn("lobby"); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getUserManager()).thenReturn(userManager); + when(userManager.getDataManager()).thenReturn(dataManager); + + org.mockito.Mockito.doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + schedulerRuns.incrementAndGet(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + org.mockito.Mockito.doAnswer(invocation -> { + asyncWrite.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + + BackendGlobalDataSync sync = new BackendGlobalDataSync(plugin, ignored -> { + }); + setField(sync, "globalDataHandler", globalDataHandler); + + HashMap data = new HashMap<>(); + data.put("ForceUpdate", new DataValueBoolean(true)); + when(globalDataHandler.getExact("lobby")).thenReturn(data); + + sync.checkGlobalData(); + + verify(plugin, never()).getMysql(); + verify(plugin).getBukkitScheduler(); + verify(globalDataHandler, never()).setBoolean("lobby", "ForceUpdate", false); + verify(plugin, never()).setUpdate(anyBoolean()); + verify(plugin, never()).update(); + verify(dataManager, never()).clearCache(); + assertNotNull(scheduled.get()); + assertTrue(schedulerRuns.get() == 1); + + sync.checkGlobalData(); + assertTrue(schedulerRuns.get() == 1); + verify(plugin, never()).setUpdate(anyBoolean()); + verify(plugin, never()).update(); + verify(dataManager, never()).clearCache(); + + scheduled.get().run(); + verify(plugin).setUpdate(true); + verify(plugin).update(); + verify(dataManager).clearCache(); + assertNotNull(asyncWrite.get()); + asyncWrite.get().run(); + verify(globalDataHandler).setBoolean("lobby", "ForceUpdate", false); + + sync.checkGlobalData(); + assertTrue(schedulerRuns.get() == 2); + } + + @Test + void bungeeTimeChangeForceChangedRunsOnTheBukkitScheduler() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings bungeeSettings = mock(BungeeSettings.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + TimeChecker timeChecker = mock(TimeChecker.class); + GlobalDataHandler globalDataHandler = mock(GlobalDataHandler.class); + AtomicReference scheduled = new AtomicReference<>(); + AtomicReference asyncCompletion = new AtomicReference<>(); + + when(plugin.getBungeeSettings()).thenReturn(bungeeSettings); + when(bungeeSettings.getServer()).thenReturn("lobby"); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getTimeChecker()).thenReturn(timeChecker); + + org.mockito.Mockito.doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + org.mockito.Mockito.doAnswer(invocation -> { + asyncCompletion.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + + BackendGlobalDataSync sync = new BackendGlobalDataSync(plugin, ignored -> { }); + setField(sync, "globalDataHandler", globalDataHandler); + + HashMap data = new HashMap<>(); + data.put("LastUpdated", new DataValueString( + "" + LocalDateTime.now().atZone(ZoneOffset.UTC).toInstant().toEpochMilli())); + data.put(TimeType.DAY.toString(), new DataValueBoolean(true)); + + assertTrue(sync.checkGlobalDataTime(TimeType.DAY, data)); + + verify(plugin).getBukkitScheduler(); + verify(timeChecker, never()).forceChanged(any(), any(Boolean.class), any(Boolean.class), any(Boolean.class)); + assertNotNull(scheduled.get()); + scheduled.get().run(); + verify(timeChecker).forceChanged(TimeType.DAY, false, true, true); + verify(globalDataHandler, never()).setBoolean("lobby", TimeType.DAY.toString(), false); + verify(globalDataHandler, never()).setData(eq("lobby"), any()); + assertNotNull(asyncCompletion.get()); + asyncCompletion.get().run(); + verify(globalDataHandler).setBoolean("lobby", TimeType.DAY.toString(), false); + verify(globalDataHandler).setData(eq("lobby"), any()); + } + + private static void setField(Object target, String fieldName, Object value) { + try { + Field field = BackendGlobalDataSync.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java new file mode 100644 index 000000000..35138136b --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/HttpBackendProxyTransportTest.java @@ -0,0 +1,775 @@ +package com.bencodez.votingplugin.backendproxy.transport; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.bencodez.simpleapi.servercomm.http.HttpBackendTransportConnector; +import com.bencodez.simpleapi.servercomm.http.HttpClientCredentialStore; +import com.bencodez.simpleapi.servercomm.http.HttpConnectionCode; +import com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.config.BungeeSettings; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.simpleapi.servercomm.global.GlobalMessageListener; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import java.net.URI; +import java.nio.file.Path; +import java.time.Instant; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.MockedStatic; + +class HttpBackendProxyTransportTest { + @TempDir Path directory; + + @Test + void dispatchesIncomingMessagesOnTheServerSchedulerAndWaitsForCompletion() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference callbackThread = new AtomicReference<>(); + CountDownLatch callbackFinished = new CountDownLatch(1); + GlobalMessageHandler handler = new GlobalMessageHandler() { + @Override public void sendMessage(JsonEnvelope envelope) { } + }; + handler.addListener(new GlobalMessageListener("test") { + @Override public void onReceive(JsonEnvelope envelope) { + callbackThread.set(Thread.currentThread().getName()); + callbackFinished.countDown(); + } + }); + doAnswer(invocation -> { + Runnable task = invocation.getArgument(1); + Thread serverThread = new Thread(task, "test-server-thread"); + serverThread.start(); + return null; + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.eq(plugin), org.mockito.ArgumentMatchers.any(Runnable.class)); + JsonEnvelope envelope = JsonEnvelope.builder("test").put("message", "payload").build(); + + new HttpBackendProxyTransport(plugin).dispatchIncoming(handler, envelope, + System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); + + assertTrue(callbackFinished.await(1, TimeUnit.SECONDS)); + assertEquals("test-server-thread", callbackThread.get()); + } + + @Test + void publicationActivatesTheSimpleApiInboundBarrier() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + setField(transport, "connector", connector); + + transport.activateAfterPublication(); + + assertTrue((boolean) field(transport, "inboundActive")); + } + + @Test + void closedPublicationGateRejectsDeliveryForReplay() { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + transport.close(); + + assertThrows(IllegalStateException.class, () -> transport.dispatchAfterPublication( + mock(GlobalMessageHandler.class), JsonEnvelope.builder("test").build())); + } + + @Test + void orderlyShutdownFlushesHandoffAfterMakingConnectorCapacity() { + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + JsonEnvelope pending = JsonEnvelope.builder("backend-stopped").build(); + when(connector.send(pending)).thenReturn(false, true); + when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + + assertTrue(HttpBackendProxyTransport.flushHandoffForShutdown(connector, List.of(pending), + System.nanoTime() + TimeUnit.SECONDS.toNanos(1))); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(connector); + order.verify(connector, org.mockito.Mockito.times(2)).send(pending); + order.verify(connector).flushOutgoing(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + @SuppressWarnings("unchecked") + void unpublishedHandoffMessagesAreDiscardedOnRollback() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + setField(transport, "connector", connector); + ((java.util.ArrayDeque) field(transport, "handoffQueue")) + .add(JsonEnvelope.builder("unpublished").build()); + + transport.close(); + + verify(connector, org.mockito.Mockito.timeout(1000)).close(); + verify(connector, org.mockito.Mockito.never()).send(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + } + + @Test + void failedStartupTransferRetainsTheUnacceptedEnvelope() { + java.util.ArrayDeque queue = new java.util.ArrayDeque<>(); + JsonEnvelope pending = JsonEnvelope.builder("pending").build(); + queue.add(pending); + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + when(connector.send(pending)).thenReturn(false); + + assertThrows(IllegalStateException.class, + () -> HttpBackendProxyTransport.transferStartupQueue(queue, connector)); + assertEquals(java.util.List.of(pending), new java.util.ArrayList<>(queue)); + } + + @Test + void failedUninstalledConnectorCloseStillReleasesOwnerAndCompletesStartup() { + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + org.mockito.Mockito.doThrow(new IllegalStateException("close failed")).when(connector).close(); + java.util.concurrent.Semaphore owner = new java.util.concurrent.Semaphore(0); + CountDownLatch completion = new CountDownLatch(1); + + assertThrows(IllegalStateException.class, + () -> HttpBackendProxyTransport.finishInitialization(false, connector, true, owner, completion)); + + verify(connector).close(); + assertEquals(1, owner.availablePermits()); + assertEquals(0L, completion.getCount()); + } + + @Test + @SuppressWarnings("unchecked") + void orderlyShutdownFlushesRecoveryQueueBeforeHandoffQueue() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + JsonEnvelope recovery = JsonEnvelope.builder("recovery").build(); + JsonEnvelope handoff = JsonEnvelope.builder("handoff").build(); + setField(transport, "connector", connector); + setField(transport, "published", true); + ((java.util.ArrayDeque) field(transport, "startupQueue")).add(recovery); + ((java.util.ArrayDeque) field(transport, "handoffQueue")).add(handoff); + when(connector.send(org.mockito.ArgumentMatchers.any(JsonEnvelope.class))).thenReturn(true); + when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + + transport.close(); + + verify(connector, org.mockito.Mockito.timeout(1000)).flushOutgoing(org.mockito.ArgumentMatchers.anyLong()); + org.mockito.InOrder order = org.mockito.Mockito.inOrder(connector); + order.verify(connector).send(recovery); + order.verify(connector).send(handoff); + order.verify(connector).flushOutgoing(org.mockito.ArgumentMatchers.anyLong()); + } + + @Test + void failedPreparationAfterCloseRestoresCapturedHttpTransport() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(mock(VotingPluginMain.class)); + HttpBackendProxyTransport failed = mock(HttpBackendProxyTransport.class); + HttpBackendProxyTransport restored = mock(HttpBackendProxyTransport.class); + JsonEnvelope queued = JsonEnvelope.builder("queued-after-failure").build(); + setField(manager, "transport", failed); + org.mockito.Mockito.doThrow(new IllegalStateException("worker still stopping")) + .when(failed).prepareForReplacement(); + when(failed.isClosedForReplacement()).thenReturn(true); + when(failed.recreatePrepared()).thenReturn(restored); + + assertThrows(IllegalStateException.class, manager::prepareForReplacement); + manager.send(queued); + + verify(restored).send(queued); + } + + @Test + void failedFlushKeepsTheRestartedHttpTransport() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(mock(VotingPluginMain.class)); + HttpBackendProxyTransport active = mock(HttpBackendProxyTransport.class); + JsonEnvelope queued = JsonEnvelope.builder("queued-after-flush-timeout").build(); + setField(manager, "transport", active); + org.mockito.Mockito.doThrow(new IllegalStateException("flush timed out")) + .when(active).prepareForReplacement(); + + assertThrows(IllegalStateException.class, manager::prepareForReplacement); + manager.send(queued); + + verify(active).send(queued); + verify(active, org.mockito.Mockito.never()).recreatePrepared(); + } + + @Test + void sendsRemainNonBlockingWhileReplacementFlushes() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(mock(VotingPluginMain.class)); + BackendProxyTransport active = mock(BackendProxyTransport.class); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(mock(VotingPluginMain.class)); + BackendProxyTransport replacementTransport = mock(BackendProxyTransport.class); + when(replacementTransport.send(org.mockito.ArgumentMatchers.any(JsonEnvelope.class))).thenReturn(true); + JsonEnvelope queued = JsonEnvelope.builder("queued-during-flush").build(); + setField(manager, "transport", active); + setField(replacement, "transport", replacementTransport); + replacement.beginPreparedTransportHandoff(); + java.util.concurrent.CountDownLatch flushStarted = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch releaseFlush = new java.util.concurrent.CountDownLatch(1); + doAnswer(invocation -> { + flushStarted.countDown(); + assertTrue(releaseFlush.await(2, java.util.concurrent.TimeUnit.SECONDS)); + return null; + }).when(active).prepareForReplacement(); + + java.util.concurrent.atomic.AtomicReference preparationFailure = new java.util.concurrent.atomic.AtomicReference<>(); + Thread preparation = new Thread(() -> { + try { + manager.prepareForReplacement(); + } catch (Throwable failure) { + preparationFailure.set(failure); + } + }); + preparation.start(); + assertTrue(flushStarted.await(1, java.util.concurrent.TimeUnit.SECONDS)); + + java.util.concurrent.CompletableFuture sent = java.util.concurrent.CompletableFuture.runAsync( + () -> manager.send(queued)); + assertDoesNotThrow(() -> sent.get(1, java.util.concurrent.TimeUnit.SECONDS)); + releaseFlush.countDown(); + preparation.join(2000L); + assertFalse(preparation.isAlive()); + assertNull(preparationFailure.get()); + + manager.completePreparedTransportHandoff(replacement); + verify(replacementTransport, org.mockito.Mockito.timeout(1000)).send(queued); + } + + @Test + void timedOutScheduledMessageCannotExecuteLater() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference scheduled = new AtomicReference<>(); + doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.eq(plugin), org.mockito.ArgumentMatchers.any(Runnable.class)); + java.util.concurrent.atomic.AtomicInteger deliveries = new java.util.concurrent.atomic.AtomicInteger(); + GlobalMessageHandler handler = new GlobalMessageHandler() { + @Override public void sendMessage(JsonEnvelope envelope) { } + }; + handler.addListener(new GlobalMessageListener("test") { + @Override public void onReceive(JsonEnvelope envelope) { deliveries.incrementAndGet(); } + }); + + assertThrows(IllegalStateException.class, + () -> new HttpBackendProxyTransport(plugin).dispatchIncoming(handler, + JsonEnvelope.builder("test").build(), System.nanoTime())); + assertTrue(scheduled.get() != null); + scheduled.get().run(); + assertEquals(0, deliveries.get(), "a delivery rejected at its deadline must stay fenced"); + } + + @Test + void interruptedScheduledMessageCannotExecuteLater() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference scheduled = new AtomicReference<>(); + doAnswer(invocation -> { + scheduled.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.eq(plugin), org.mockito.ArgumentMatchers.any(Runnable.class)); + java.util.concurrent.atomic.AtomicInteger deliveries = new java.util.concurrent.atomic.AtomicInteger(); + GlobalMessageHandler handler = new GlobalMessageHandler() { + @Override public void sendMessage(JsonEnvelope envelope) { } + }; + handler.addListener(new GlobalMessageListener("test") { + @Override public void onReceive(JsonEnvelope envelope) { deliveries.incrementAndGet(); } + }); + AtomicReference dispatchFailure = new AtomicReference<>(); + Thread dispatch = new Thread(() -> { + try { + new HttpBackendProxyTransport(plugin).dispatchIncoming(handler, + JsonEnvelope.builder("test").build(), System.nanoTime() + TimeUnit.SECONDS.toNanos(5)); + } catch (Throwable thrown) { + dispatchFailure.set(thrown); + } + }); + dispatch.start(); + while (scheduled.get() == null) Thread.onSpinWait(); + dispatch.interrupt(); + dispatch.join(TimeUnit.SECONDS.toMillis(1)); + + assertTrue(dispatchFailure.get() instanceof IllegalStateException); + scheduled.get().run(); + assertEquals(0, deliveries.get(), "an interrupted delivery must stay fenced"); + } + + @Test + void validatesInitialConnectionCodeSynchronously() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(settings.getServer()).thenReturn("lobby-1"); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + + when(settings.getHttpConnectionCode()).thenReturn("malformed"); + assertThrows(IllegalStateException.class, transport::validate); + + when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().minusSeconds(1)).encode()); + assertThrows(IllegalStateException.class, transport::validate); + + when(settings.getHttpConnectionCode()).thenReturn(code("survival", Instant.now().plusSeconds(60)).encode()); + assertThrows(IllegalStateException.class, transport::validate); + assertThrows(IllegalStateException.class, () -> transport.start(mock(GlobalMessageHandler.class)), + "invalid configuration must fail before the enrollment worker starts"); + + when(settings.getHttpConnectionCode()).thenReturn(code("lobby-1", Instant.now().plusSeconds(60)).encode()); + assertDoesNotThrow(() -> transport.validate()); + } + + @Test + void ordinaryStartupRetriesTransientInitialEnrollmentWithBoundedBackoff() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + HttpConnectionCode enrollment = code("lobby-1", Instant.now().plusSeconds(60)); + List delays = new ArrayList<>(); + java.util.concurrent.atomic.AtomicInteger attempts = new java.util.concurrent.atomic.AtomicInteger(); + + try (MockedStatic connector = org.mockito.Mockito.mockStatic(HttpBackendTransportConnector.class)) { + connector.when(() -> HttpBackendTransportConnector.enroll(enrollment, "lobby-1", directory)) + .thenAnswer(ignored -> { + if (attempts.getAndIncrement() == 0) throw new IOException("proxy unavailable"); + return null; + }); + assertTrue(transport.enrollForStartup(enrollment, "lobby-1", directory, true, + delay -> { delays.add(delay); return true; })); + } + + assertEquals(2, attempts.get()); + assertEquals(List.of(1_000L), delays); + } + + @Test + void replacementCancelsRetryingInitialEnrollmentWithoutCredential() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + Path credentials = directory.resolve("http"); + java.lang.reflect.Field directoryField = HttpBackendProxyTransport.class.getDeclaredField("configuredDirectory"); + directoryField.setAccessible(true); + directoryField.set(transport, credentials); + java.lang.reflect.Field ownersField = HttpBackendProxyTransport.class.getDeclaredField("DIRECTORY_OWNERS"); + ownersField.setAccessible(true); + @SuppressWarnings("unchecked") + var owners = (java.util.concurrent.ConcurrentHashMap) ownersField.get(null); + Path ownerKey = credentials.toAbsolutePath().normalize(); + java.util.concurrent.Semaphore owner = new java.util.concurrent.Semaphore(0); + owners.put(ownerKey, owner); + CountDownLatch workerStarted = new CountDownLatch(1); + Thread retryingEnrollment = new Thread(() -> { + workerStarted.countDown(); + try { Thread.sleep(Long.MAX_VALUE); } + catch (InterruptedException expected) { Thread.currentThread().interrupt(); } + finally { owner.release(); } + }); + java.lang.reflect.Field workerField = HttpBackendProxyTransport.class.getDeclaredField("worker"); + workerField.setAccessible(true); + workerField.set(transport, retryingEnrollment); + retryingEnrollment.start(); + assertTrue(workerStarted.await(1, TimeUnit.SECONDS)); + JsonEnvelope queued = JsonEnvelope.builder("queued-before-replacement").build(); + transport.send(queued); + + assertDoesNotThrow(transport::prepareForReplacement, + "a staged Control replacement must cancel a first-time retry loop without an active credential"); + java.lang.reflect.Field restoreAbsent = + HttpBackendProxyTransport.class.getDeclaredField("restoreUnenrolledState"); + restoreAbsent.setAccessible(true); + assertTrue(restoreAbsent.getBoolean(transport), "rollback must remember that no credential existed"); + assertEquals(List.of(queued), transport.drainPreparedMessages(), + "messages accepted before replacement must remain available for handoff"); + + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (owner.availablePermits() == 0 && System.nanoTime() < deadline) Thread.onSpinWait(); + assertEquals(1, owner.availablePermits(), + "cancelled enrollment must release the credential-directory ownership"); + owners.remove(ownerKey, owner); + } + + @Test + void replacementSnapshotsInitialEnrollmentPublishedWhileCancellationCompletes() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("original-proxy"), + "proxy.example.test"); + HttpConnectionCode original = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:8443/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "G".repeat(43)); + setField(transport, "configuredDirectory", credentials); + setField(transport, "configuredServerId", "lobby-1"); + setField(transport, "configuredConnectionCode", original.encode()); + CountDownLatch workerStarted = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>(); + Thread finishingEnrollment = new Thread(() -> { + workerStarted.countDown(); + try { + Thread.sleep(Long.MAX_VALUE); + } catch (InterruptedException expected) { + try { + HttpClientCredentialStore.saveEnrolled(credentials, original, + identity.issueClientCertificate("lobby-1")); + } catch (Throwable thrown) { failure.set(thrown); } + } + }); + setField(transport, "worker", finishingEnrollment); + finishingEnrollment.start(); + assertTrue(workerStarted.await(1, TimeUnit.SECONDS)); + + transport.prepareForReplacement(); + + assertNull(failure.get()); + assertNotNull(field(transport, "configuredCredentialGeneration"), + "a credential published while cancellation finishes must be retained for rollback"); + assertFalse((boolean) field(transport, "restoreUnenrolledState")); + } + + @Test + void rollbackRestoresAnExplicitlyUnenrolledCredentialState() throws Exception { + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("replacement-proxy"), + "proxy.example.test"); + HttpConnectionCode stagedCode = new HttpConnectionCode("lobby-1", + URI.create("https://proxy.example.test:1297/"), identity.serverCertificatePin(), + identity.caCertificatePin(), Instant.now().plusSeconds(60), "E".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, stagedCode, + identity.issueClientCertificate("lobby-1")); + assertTrue(HttpClientCredentialStore.hasEnrolledProfile(credentials)); + + HttpConnectionCode original = code("lobby-1", Instant.now().plusSeconds(60)); + HttpBackendProxyTransport.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode()); + + assertFalse(HttpClientCredentialStore.hasEnrolledProfile(credentials), + "a failed staged first enrollment must not remain active during rollback"); + } + + @Test + void rollbackRetainsEnrollmentIssuedForTheOriginalOneTimeCode() throws Exception { + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("same-code-proxy"), + "proxy.example.test"); + HttpConnectionCode original = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "F".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1")); + + HttpBackendProxyTransport.restoreUnenrolledCredentialState(credentials, "lobby-1", original.encode()); + + assertTrue(HttpClientCredentialStore.hasEnrolledProfile(credentials), + "a credential issued for the consumed original code must remain recoverable"); + } + + @Test + void stagedEnrollmentRemainsFailFast() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + HttpConnectionCode enrollment = code("lobby-1", Instant.now().plusSeconds(60)); + try (MockedStatic connector = org.mockito.Mockito.mockStatic(HttpBackendTransportConnector.class)) { + connector.when(() -> HttpBackendTransportConnector.enroll(enrollment, "lobby-1", directory)) + .thenThrow(new IOException("proxy unavailable")); + assertThrows(IOException.class, + () -> transport.enrollForStartup(enrollment, "lobby-1", directory, false, + delay -> { throw new AssertionError("staged enrollment must not retry"); })); + } + } + + @Test + void freshConnectionCodeOverridesAnExistingEnrollment() throws Exception { + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "proxy.example.test"); + HttpConnectionCode original = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().minusSeconds(1), "A".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, original, identity.issueClientCertificate("lobby-1")); + assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", "")); + assertNull(HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", original.encode()), + "the already-consumed code must not be retried, even after it expires"); + + HttpConnectionCode replacement = new HttpConnectionCode("lobby-1", original.endpoint(), original.serverCertificatePin(), + original.caCertificatePin(), Instant.now().plusSeconds(60), "B".repeat(43)); + assertEquals(replacement.encode(), HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", replacement.encode()).encode()); + assertThrows(IllegalStateException.class, + () -> HttpBackendProxyTransport.enrollmentCode(credentials, "lobby-1", "malformed")); + } + + @Test + void failedReplacementFlushResumesTheExistingConnector() { + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(false); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + + assertThrows(IllegalStateException.class, + () -> transport.flushForReplacement(connector, System.nanoTime())); + + verify(connector).start(); + } + + @Test + void closeNeverWaitsForSetupOnTheCallingThread() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + CountDownLatch started = new CountDownLatch(1), release = new CountDownLatch(1); + Thread blocked = new Thread(() -> { + started.countDown(); + while (release.getCount() != 0) try { release.await(); } + catch (InterruptedException ignored) { /* Simulate setup I/O that has not unwound yet. */ } + }); + blocked.start(); + assertTrue(started.await(1, TimeUnit.SECONDS)); + java.lang.reflect.Field worker = HttpBackendProxyTransport.class.getDeclaredField("worker"); + worker.setAccessible(true); + worker.set(transport, blocked); + + long startedAt = System.nanoTime(); + transport.close(); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + try { assertTrue(elapsedMillis < 500, "close blocked the calling thread for " + elapsedMillis + " ms"); } + finally { release.countDown(); blocked.join(TimeUnit.SECONDS.toMillis(1)); } + } + + @Test + void prepareForReplacementSnapshotsThePersistedCredentialGeneration() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("replacement-proxy"), "proxy.example.test"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "D".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, code, identity.issueClientCertificate("lobby-1")); + HttpClientCredentialStore.ActiveCredentialGeneration generation = + HttpClientCredentialStore.snapshotActiveGeneration(credentials); + java.lang.reflect.Field directoryField = HttpBackendProxyTransport.class.getDeclaredField("configuredDirectory"); + directoryField.setAccessible(true); + directoryField.set(transport, credentials); + java.lang.reflect.Field connectorField = HttpBackendProxyTransport.class.getDeclaredField("connector"); + connectorField.setAccessible(true); + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + when(connector.flushOutgoing(org.mockito.ArgumentMatchers.anyLong())).thenReturn(true); + connectorField.set(transport, connector); + + assertDoesNotThrow(transport::prepareForReplacement); + java.lang.reflect.Field generationField = + HttpBackendProxyTransport.class.getDeclaredField("configuredCredentialGeneration"); + generationField.setAccessible(true); + assertEquals(generation, generationField.get(transport)); + } + + @Test + @SuppressWarnings("unchecked") + void validationWaitsForThePreviousDirectoryOwnerBeforeReadinessFailure() throws Exception { + Path credentials = directory.resolve("http"); + HttpTlsIdentity identity = HttpTlsIdentity.loadOrCreate(directory.resolve("proxy-owner"), "proxy.example.test"); + HttpConnectionCode code = new HttpConnectionCode("lobby-1", URI.create("https://proxy.example.test:1297/"), + identity.serverCertificatePin(), identity.caCertificatePin(), Instant.now().plusSeconds(60), "C".repeat(43)); + HttpClientCredentialStore.saveEnrolled(credentials, code, identity.issueClientCertificate("lobby-1")); + VotingPluginMain plugin = mock(VotingPluginMain.class); + BungeeSettings settings = mock(BungeeSettings.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getBungeeSettings()).thenReturn(settings); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(settings.getServer()).thenReturn("lobby-1"); + when(settings.getHttpConnectionCode()).thenReturn(""); + java.lang.reflect.Field ownersField = HttpBackendProxyTransport.class.getDeclaredField("DIRECTORY_OWNERS"); + ownersField.setAccessible(true); + var owners = (java.util.concurrent.ConcurrentHashMap) ownersField.get(null); + java.util.concurrent.Semaphore predecessor = new java.util.concurrent.Semaphore(0); + owners.put(credentials.toAbsolutePath().normalize(), predecessor); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + transport.start(mock(GlobalMessageHandler.class)); + java.util.concurrent.atomic.AtomicReference failure = new java.util.concurrent.atomic.AtomicReference<>(); + CountDownLatch finished = new CountDownLatch(1); + Thread validation = new Thread(() -> { + try { transport.validate(System.nanoTime() + TimeUnit.SECONDS.toNanos(1)); } + catch (Throwable thrown) { failure.set(thrown); } + finally { finished.countDown(); } + }); + validation.start(); + assertFalse(finished.await(150, TimeUnit.MILLISECONDS), "validation published before journal handoff"); + predecessor.release(); + assertTrue(finished.await(3, TimeUnit.SECONDS)); + validation.join(TimeUnit.SECONDS.toMillis(1)); + assertTrue(failure.get() instanceof IllegalStateException); + java.lang.reflect.Field connectorField = HttpBackendProxyTransport.class.getDeclaredField("connector"); + connectorField.setAccessible(true); + assertTrue(connectorField.get(transport) instanceof HttpBackendTransportConnector, + "a readiness timeout must not tear down the connector's background retry loop"); + transport.close(); + } + + @Test + void preparedHandoffMessagesStayAheadOfNewlyPublishedSendsWhenConnectorIsFull() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + HttpBackendTransportConnector connector = mock(HttpBackendTransportConnector.class); + when(connector.send(org.mockito.ArgumentMatchers.any(JsonEnvelope.class))).thenReturn(false); + setField(transport, "connector", connector); + JsonEnvelope acceptedBeforePublication = JsonEnvelope.builder("old").build(); + JsonEnvelope sentAfterPublication = JsonEnvelope.builder("new").build(); + + transport.beginPreparedHandoff(); + transport.send(sentAfterPublication); + transport.acceptHandoffMessages(List.of(acceptedBeforePublication)); + + assertEquals(List.of(acceptedBeforePublication, sentAfterPublication), transport.handoffMessagesSnapshot()); + transport.close(); + } + + @Test + @SuppressWarnings("unchecked") + void reservationKeepsPreparedHandoffWithinTheReplacementCapacity() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin); + HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin); + setField(previous, "preparedTransport", oldTransport); + setField(replacement, "transport", newTransport); + java.util.ArrayDeque oldQueue = + (java.util.ArrayDeque) field(oldTransport, "handoffQueue"); + List oldMessages = new ArrayList<>(); + List preparedMessages = new ArrayList<>(); + List replacementMessages = new ArrayList<>(); + for (int index = 0; index < 2048; index++) { + JsonEnvelope envelope = JsonEnvelope.builder("old-" + index).build(); + oldQueue.addLast(envelope); + oldMessages.add(envelope); + } + for (int index = 0; index < 1024; index++) { + JsonEnvelope envelope = JsonEnvelope.builder("prepared-" + index).build(); + previous.send(envelope); + preparedMessages.add(envelope); + } + + replacement.beginPreparedHttpHandoff(); + previous.reservePreparedTransportHandoff(replacement); + for (int index = 0; index < 2048; index++) { + JsonEnvelope envelope = JsonEnvelope.builder("new-" + index).build(); + replacement.send(envelope); + replacementMessages.add(envelope); + } + + assertDoesNotThrow(() -> previous.completePreparedTransportHandoff(replacement)); + List queued = newTransport.handoffMessagesSnapshot(); + assertEquals(4096, queued.size()); + assertEquals(oldMessages, queued.subList(0, 2048)); + assertEquals(preparedMessages, queued.subList(2048, 3072)); + assertEquals(replacementMessages.subList(0, 1024), queued.subList(3072, 4096)); + assertEquals(0, oldTransport.preparedMessageCount()); + assertTrue(((java.util.ArrayDeque) field(previous, "preparedSends")).isEmpty()); + newTransport.close(); + } + + @Test + @SuppressWarnings("unchecked") + void failedHandoffAdmissionLeavesTheOldPreparedQueuesIntact() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BackendProxyTransportManager previous = new BackendProxyTransportManager(plugin); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(plugin); + HttpBackendProxyTransport oldTransport = new HttpBackendProxyTransport(plugin); + HttpBackendProxyTransport newTransport = new HttpBackendProxyTransport(plugin); + JsonEnvelope old = JsonEnvelope.builder("old").build(); + JsonEnvelope prepared = JsonEnvelope.builder("prepared").build(); + setField(previous, "preparedTransport", oldTransport); + setField(replacement, "transport", newTransport); + ((java.util.ArrayDeque) field(oldTransport, "startupQueue")).addLast(old); + previous.send(prepared); + java.util.ArrayDeque replacementQueue = + (java.util.ArrayDeque) field(newTransport, "handoffQueue"); + for (int index = 0; index < 4096; index++) + replacementQueue.addLast(JsonEnvelope.builder("replacement-" + index).build()); + + assertThrows(IllegalStateException.class, () -> previous.completePreparedTransportHandoff(replacement)); + + assertEquals(List.of(old), oldTransport.preparedMessagesSnapshot()); + assertEquals(List.of(prepared), new ArrayList<>((java.util.ArrayDeque) field(previous, "preparedSends"))); + assertNull(field(previous, "forwardingManager")); + } + + @Test + @SuppressWarnings("unchecked") + void preparedDrainPreservesStartupAndExistingHandoffMessagesInFifoOrder() throws Exception { + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(mock(VotingPluginMain.class)); + JsonEnvelope startup = JsonEnvelope.builder("startup").build(); + JsonEnvelope handoff = JsonEnvelope.builder("handoff").build(); + ((java.util.ArrayDeque) field(transport, "startupQueue")).add(startup); + ((java.util.ArrayDeque) field(transport, "handoffQueue")).add(handoff); + + assertEquals(List.of(startup, handoff), transport.drainPreparedMessages()); + assertTrue(((java.util.ArrayDeque) field(transport, "startupQueue")).isEmpty()); + assertTrue(((java.util.ArrayDeque) field(transport, "handoffQueue")).isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + void rollbackRecreateKeepsAnOversizedCombinedQueueInTheDrainedHandoffLane() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + HttpBackendProxyTransport transport = new HttpBackendProxyTransport(plugin); + Path credentials = directory.resolve("http"); + setField(transport, "configuredDirectory", credentials); + setField(transport, "configuredServerId", "lobby-1"); + setField(transport, "configuredConnectionCode", ""); + setField(transport, "configuredMessageHandler", mock(GlobalMessageHandler.class)); + setField(transport, "restoreUnenrolledState", true); + List queued = new ArrayList<>(); + JsonEnvelope startup = JsonEnvelope.builder("startup").build(); + queued.add(startup); + ((java.util.ArrayDeque) field(transport, "startupQueue")).add(startup); + for (int index = 0; index < 1024; index++) { + JsonEnvelope handoff = JsonEnvelope.builder("handoff-" + index).build(); + queued.add(handoff); + ((java.util.ArrayDeque) field(transport, "handoffQueue")).add(handoff); + } + + java.lang.reflect.Field ownersField = HttpBackendProxyTransport.class.getDeclaredField("DIRECTORY_OWNERS"); + ownersField.setAccessible(true); + var owners = (java.util.concurrent.ConcurrentHashMap) ownersField.get(null); + Path ownerKey = credentials.toAbsolutePath().normalize(); + java.util.concurrent.Semaphore owner = new java.util.concurrent.Semaphore(0); + owners.put(ownerKey, owner); + HttpBackendProxyTransport restored = null; + Thread setup = null; + try { + restored = transport.recreatePrepared(); + assertTrue(((java.util.ArrayDeque) field(restored, "startupQueue")).isEmpty(), + "rollback must not synchronously overfill the connector's 1024-entry queue"); + assertEquals(queued, + new java.util.ArrayList<>((java.util.ArrayDeque) field(restored, "handoffQueue"))); + assertTrue(((java.util.ArrayDeque) field(transport, "startupQueue")).isEmpty()); + assertTrue(((java.util.ArrayDeque) field(transport, "handoffQueue")).isEmpty()); + setup = (Thread) field(restored, "worker"); + } finally { + if (restored != null) restored.close(); + if (setup != null) setup.join(TimeUnit.SECONDS.toMillis(1)); + owners.remove(ownerKey, owner); + } + } + + private static HttpConnectionCode code(String serverId, Instant expiry) { + return new HttpConnectionCode(serverId, URI.create("https://proxy.example.test:1297/"), "a".repeat(64), + "b".repeat(64), expiry, "A".repeat(43)); + } + + private static void setField(Object target, String name, Object value) throws Exception { + java.lang.reflect.Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static Object field(Object target, String name) throws Exception { + java.lang.reflect.Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java index fc97bb834..ff2590734 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/backendproxy/transport/RedisBackendProxyTransportTest.java @@ -2,18 +2,31 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; import java.lang.reflect.Field; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CopyOnWriteArrayList; import org.junit.jupiter.api.Test; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; import com.bencodez.simpleapi.servercomm.global.GlobalMessageHandler; +import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.backendproxy.cache.ProcessedVoteCache; +import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.VotingPluginWire; import redis.clients.jedis.DefaultJedisClientConfig; @@ -65,4 +78,847 @@ void legacyHandoffBufferDegradesForAnOversizedEnvelope() throws Exception { verify(messages).onMessage(envelope); } + + @Test + void retiredListenerDropsLegacyDeliveriesAfterShutdownTimeout() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + transport.fenceAfterHandoff(); + + transport.dispatchLegacy(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build()); + + verifyNoInteractions(messages); + } + + @Test + void retiredListenerDoesNotReserveOrDispatchDeliveryIds() throws Exception { + ProcessedVoteCache cache = new ProcessedVoteCache(); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + transport.fenceAfterHandoff(); + String deliveryId = "00000000-0000-0000-0000-000000000001"; + + transport.dispatchIdentified(JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(), deliveryId); + + assertTrue(cache.reserveRedisDelivery(deliveryId), "retired listener must not consume shared deduplication state"); + verifyNoInteractions(messages); + } + + @Test + void standbyBuffersIdentifiedDeliveriesUntilItIsPublished() throws Exception { + ProcessedVoteCache cache = new ProcessedVoteCache(); + cache.registerRedisSubscriber(new Object()); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + Field identity = RedisBackendProxyTransport.class.getDeclaredField("subscriberIdentity"); + identity.setAccessible(true); + cache.registerRedisSubscriber(identity.get(transport)); + Field standby = RedisBackendProxyTransport.class.getDeclaredField("standbySubscriber"); + standby.setAccessible(true); + standby.setBoolean(transport, true); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + String deliveryId = "00000000-0000-0000-0000-000000000002"; + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE) + .put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).build(); + + transport.dispatchIdentified(envelope, deliveryId); + + verifyNoInteractions(messages); + transport.activateAfterHandoff(); + verifyNoInteractions(messages); + transport.replayAfterHandoffPublication(); + verify(messages).onMessage(envelope); + assertFalse(cache.reserveRedisDelivery(deliveryId), + "publication must reserve the delivery exactly when its buffered callback is replayed"); + } + + @Test + void standbyReplaysMixedLegacyAndIdentifiedDeliveriesInArrivalOrder() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + setBoolean(transport, "standbySubscriber", true); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + JsonEnvelope legacy = JsonEnvelope.builder("legacy").build(); + String deliveryId = "00000000-0000-0000-0000-000000000003"; + JsonEnvelope identified = JsonEnvelope.builder("identified") + .put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).build(); + + transport.dispatchLegacy(legacy); + transport.dispatchIdentified(identified, deliveryId); + transport.activateAfterHandoff(); + transport.replayAfterHandoffPublication(); + + org.mockito.InOrder order = org.mockito.Mockito.inOrder(messages); + order.verify(messages).onMessage(legacy); + order.verify(messages).onMessage(identified); + } + + @Test + void legacyCopiesDoNotExposeReliableDeliveryIdentity() throws Exception { + ProcessedVoteCache cache = new ProcessedVoteCache(); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + Field identity = RedisBackendProxyTransport.class.getDeclaredField("subscriberIdentity"); + identity.setAccessible(true); + cache.registerRedisSubscriber(identity.get(transport)); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + String deliveryId = "00000000-0000-0000-0000-000000000099"; + JsonEnvelope mixed = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE) + .put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).put("payload", "legacy").build(); + + transport.dispatchLegacy(mixed); + + org.mockito.ArgumentCaptor captured = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(messages).onMessage(captured.capture()); + assertFalse(captured.getValue().getFields().containsKey(VotingPluginWire.K_REDIS_DELIVERY_ID)); + assertEquals("legacy", captured.getValue().getFields().get("payload")); + } + + @Test + void identifiedStandbyOverflowAbortsPublication() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, mock(ProcessedVoteCache.class)); + setBoolean(transport, "standbySubscriber", true); + setField(transport, "messageHandler", mock(GlobalMessageHandler.class)); + for (int index = 0; index <= RedisBackendProxyTransport.MAX_IDENTIFIED_HANDOFF_DELIVERIES; index++) { + String deliveryId = String.format("00000000-0000-0000-0000-%012d", index); + transport.dispatchIdentified(JsonEnvelope.builder("identified") + .put(VotingPluginWire.K_REDIS_DELIVERY_ID, deliveryId).build(), deliveryId); + } + + assertThrows(IllegalStateException.class, transport::activateAfterHandoff); + } + + @Test + void oversizedLegacyStandbyDeliveryAbortsPublicationWithoutDispatching() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, mock(ProcessedVoteCache.class)); + setBoolean(transport, "standbySubscriber", true); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + JsonEnvelope oversized = JsonEnvelope.builder("legacy") + .put("payload", "x".repeat(ProcessedVoteCache.MAX_LEGACY_REDIS_DELIVERY_BYTES + 1)).build(); + + transport.dispatchLegacy(oversized); + + verifyNoInteractions(messages); + assertThrows(IllegalStateException.class, transport::activateAfterHandoff); + } + + @Test + void legacyStandbyBufferOverflowAbortsPublicationWithoutDispatching() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, mock(ProcessedVoteCache.class)); + setBoolean(transport, "standbySubscriber", true); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + + for (int index = 0; index <= RedisBackendProxyTransport.MAX_LEGACY_HANDOFF_DELIVERIES; index++) + transport.dispatchLegacy(JsonEnvelope.builder("legacy-" + index).build()); + + verifyNoInteractions(messages); + assertThrows(IllegalStateException.class, transport::activateAfterHandoff); + } + + @Test + void activeLegacyAccountingOverflowAbortsLaggingStandbyPublication() throws Exception { + ProcessedVoteCache cache = new ProcessedVoteCache(); + Object previousIdentity = new Object(); + cache.registerRedisSubscriber(previousIdentity); + RedisBackendProxyTransport replacement = new RedisBackendProxyTransport(null, cache); + Field identity = RedisBackendProxyTransport.class.getDeclaredField("subscriberIdentity"); + identity.setAccessible(true); + cache.registerRedisSubscriber(identity.get(replacement)); + setBoolean(replacement, "standbySubscriber", true); + + for (int index = 0; index <= 4096; index++) { + cache.reserveLegacyRedisDelivery(previousIdentity, "old-only-" + index); + } + + assertThrows(IllegalStateException.class, replacement::activateAfterHandoff); + } + + @Test + void fencingWaitsForAnIdentifiedMessageHandler() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, new ProcessedVoteCache()); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + return null; + }).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + Thread delivery = new Thread(() -> transport.dispatchIdentified( + JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(), "delivery")); + delivery.start(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + + CountDownLatch fenced = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference fenceFailure = + new java.util.concurrent.atomic.AtomicReference<>(); + Thread retirement = new Thread(() -> { + try { + transport.fenceAfterHandoff(); + } catch (Throwable failure) { + fenceFailure.set(failure); + } finally { + fenced.countDown(); + } + }); + retirement.start(); + try { + assertFalse(fenced.await(100, TimeUnit.MILLISECONDS)); + retirement.interrupt(); + assertTrue(fenced.await(1, TimeUnit.SECONDS)); + } finally { + release.countDown(); + } + delivery.join(TimeUnit.SECONDS.toMillis(1)); + retirement.join(TimeUnit.SECONDS.toMillis(1)); + assertFalse(delivery.isAlive()); + assertFalse(retirement.isAlive()); + assertTrue(fenceFailure.get() instanceof RedisBackendProxyTransport.HandoffQuiescenceException); + } + + @Test + void managerDoesNotHoldItsMonitorWhileWaitingForRedisCallbackReplies() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(null); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveLegacyRedisDelivery(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + RedisBackendProxyTransport transport = spy(new RedisBackendProxyTransport(null, cache)); + setField(manager, "transport", transport); + CountDownLatch callbackEntered = new CountDownLatch(1); + CountDownLatch allowReply = new CountDownLatch(1); + CountDownLatch callbackCompleted = new CountDownLatch(1); + JsonEnvelope reply = JsonEnvelope.builder("reply").build(); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + callbackEntered.countDown(); + allowReply.await(); + manager.send(reply); + callbackCompleted.countDown(); + return null; + }).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + setField(transport, "messageHandler", messages); + + Thread delivery = new Thread(() -> transport.dispatchLegacy( + JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build())); + delivery.start(); + assertTrue(callbackEntered.await(1, TimeUnit.SECONDS)); + + java.util.concurrent.atomic.AtomicReference closeFailure = + new java.util.concurrent.atomic.AtomicReference<>(); + BackendProxyTransportManager replacementManager = new BackendProxyTransportManager(null, cache); + RedisBackendProxyTransport replacementTransport = mock(RedisBackendProxyTransport.class); + when(replacementTransport.send(reply)).thenReturn(true); + setField(replacementManager, "transport", replacementTransport); + replacementManager.beginPreparedTransportHandoff(); + Thread retirement = new Thread(() -> { + try { + manager.closeRedisForHandoff(replacementManager); + } catch (Throwable failure) { + closeFailure.set(failure); + } + }); + retirement.start(); + try { + assertTrue(awaitRetiredAfterHandoff(transport)); + + allowReply.countDown(); + assertTrue(callbackCompleted.await(1, TimeUnit.SECONDS)); + retirement.join(TimeUnit.SECONDS.toMillis(1)); + delivery.join(TimeUnit.SECONDS.toMillis(1)); + assertFalse(retirement.isAlive()); + assertFalse(delivery.isAlive()); + assertNull(closeFailure.get()); + // The callback could re-enter manager.send because retirement did not hold + // its monitor. The same-Redis fence retains that reply until the Bukkit + // publication path admits it to the staged replacement. + verify(transport, never()).send(reply); + verify(replacementTransport, never()).send(reply); + manager.completePreparedTransportHandoff(replacementManager); + verify(replacementTransport, org.mockito.Mockito.timeout(1_000).times(1)).send(reply); + } finally { + allowReply.countDown(); + if (retirement.isAlive()) retirement.interrupt(); + delivery.join(TimeUnit.SECONDS.toMillis(1)); + retirement.join(TimeUnit.SECONDS.toMillis(1)); + } + } + + @Test + void activationFailureTransfersEveryBufferedStandbyOverlapBeforeReplacementClose() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveLegacyRedisDelivery(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + when(cache.reserveRedisDelivery("00000000-0000-0000-0000-000000001594")).thenReturn(true); + BackendProxyTransportManager previous = new BackendProxyTransportManager(null, cache); + BackendProxyTransportManager replacement = new BackendProxyTransportManager(null, cache); + RedisBackendProxyTransport oldTransport = mock(RedisBackendProxyTransport.class); + RedisBackendProxyTransport standby = spy(new RedisBackendProxyTransport(null, cache)); + JsonEnvelope legacy = JsonEnvelope.builder("legacy-overlap").build(); + JsonEnvelope identified = JsonEnvelope.builder("identified-overlap").build(); + setBoolean(standby, "standbySubscriber", true); + setField(standby, "messageHandler", mock(GlobalMessageHandler.class)); + standby.dispatchLegacy(legacy); + standby.dispatchIdentified(identified, "00000000-0000-0000-0000-000000001594"); + org.mockito.Mockito.doThrow(new IllegalStateException("standby promotion rejected")) + .when(standby).activateAfterHandoff(); + setField(previous, "transport", oldTransport); + setField(replacement, "transport", standby); + + assertThrows(IllegalStateException.class, () -> previous.completeRedisHandoff(replacement)); + + @SuppressWarnings("unchecked") + org.mockito.ArgumentCaptor> restored = + org.mockito.ArgumentCaptor.forClass(java.util.List.class); + verify(oldTransport, times(1)).restoreAfterFailedHandoff(restored.capture()); + assertEquals(java.util.List.of(legacy, identified), restored.getValue(), + "rollback must restore every staged overlap delivery in arrival order"); + + standby.close(); + assertTrue(standby.detachReplayForFailedHandoff().isEmpty(), + "closing the failed staged transport must not discard an untransferred delivery"); + } + + @Test + void rollbackDoesNotRestartAListenerThatMissedItsHandoffShutdownDeadline() throws Exception { + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, mock(ProcessedVoteCache.class)); + Thread retiredListener = mock(Thread.class); + when(retiredListener.isAlive()).thenReturn(true); + setField(transport, "listenerThread", retiredListener); + setField(transport, "handoffMessageHandler", mock(GlobalMessageHandler.class)); + setField(transport, "retiredAfterHandoff", true); + + assertThrows(IllegalStateException.class, () -> transport.restoreAfterFailedHandoff(java.util.List.of())); + verify(retiredListener).isAlive(); + assertTrue(getBoolean(transport, "retiredAfterHandoff"), + "a listener that missed shutdown must remain fenced after rollback is rejected"); + } + + @Test + void legacyPromotionWaitsForReservedDeliveryBeforeReplayingStandbyCopy() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveLegacyRedisDelivery(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyString())).thenReturn(true, false); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(true); + RedisBackendProxyTransport previous = new RedisBackendProxyTransport(null, cache); + RedisBackendProxyTransport replacement = new RedisBackendProxyTransport(null, cache); + setBoolean(replacement, "standbySubscriber", true); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + GlobalMessageHandler previousMessages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + entered.countDown(); + release.await(); + return null; + }).when(previousMessages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + GlobalMessageHandler replacementMessages = mock(GlobalMessageHandler.class); + setField(previous, "messageHandler", previousMessages); + setField(replacement, "messageHandler", replacementMessages); + JsonEnvelope envelope = JsonEnvelope.builder(VotingPluginWire.SUB_VOTE_UPDATE).build(); + + Thread delivery = new Thread(() -> previous.dispatchLegacy(envelope)); + delivery.start(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + replacement.dispatchLegacy(envelope); + CountDownLatch promoted = new CountDownLatch(1); + Thread promotion = new Thread(() -> { + previous.fenceAfterHandoff(); + replacement.activateAfterHandoff(); + promoted.countDown(); + }); + promotion.start(); + assertFalse(promoted.await(100, TimeUnit.MILLISECONDS)); + + release.countDown(); + assertTrue(promoted.await(1, TimeUnit.SECONDS)); + delivery.join(TimeUnit.SECONDS.toMillis(1)); + promotion.join(TimeUnit.SECONDS.toMillis(1)); + verify(previousMessages).onMessage(envelope); + verifyNoInteractions(replacementMessages); + } + + @Test + void replayCompletesBeforeNewlyAdmittedDeliveries() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery("new-delivery")).thenReturn(true); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + JsonEnvelope replayed = JsonEnvelope.builder("old").build(); + JsonEnvelope newer = JsonEnvelope.builder("new").build(); + CopyOnWriteArrayList order = new CopyOnWriteArrayList<>(); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + JsonEnvelope envelope = invocation.getArgument(0); + order.add(envelope); + if (envelope == replayed) { + Thread arrival = new Thread(() -> transport.dispatchIdentified(newer, "new-delivery")); + arrival.start(); + arrival.join(TimeUnit.SECONDS.toMillis(1)); + } + return null; + }).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + Field handler = RedisBackendProxyTransport.class.getDeclaredField("messageHandler"); + handler.setAccessible(true); + handler.set(transport, messages); + transport.dispatchLegacy(replayed); + + transport.activateAfterHandoff(); + transport.replayAfterHandoffPublication(); + + assertEquals(java.util.List.of(replayed, newer), order); + } + + @Test + void replayUsesBoundedBukkitBatchesFromItsDedicatedWorker() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + java.util.concurrent.atomic.AtomicInteger scheduled = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(invocation -> { + scheduled.incrementAndGet(); + ((Runnable) invocation.getArgument(1)).run(); + return null; + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), + org.mockito.ArgumentMatchers.any(Runnable.class)); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache); + setBoolean(transport, "standbySubscriber", true); + CountDownLatch replayed = new CountDownLatch(RedisBackendProxyTransport.REPLAY_BATCH_SIZE + 1); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + replayed.countDown(); + return null; + }).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + setField(transport, "messageHandler", messages); + for (int index = 0; index <= RedisBackendProxyTransport.REPLAY_BATCH_SIZE; index++) + transport.dispatchLegacy(JsonEnvelope.builder("replay-" + index).build()); + + transport.activateAfterHandoff(); + transport.replayAfterHandoffPublication(); + + assertTrue(replayed.await(1, TimeUnit.SECONDS)); + assertEquals(2, scheduled.get(), "a replay larger than one batch must not run in one Bukkit task"); + } + + @Test + void fullReplayQueueBackpressuresNewCallbacksAndCloseWakesThemWithoutReserving() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery("new-delivery")).thenReturn(true); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + setField(transport, "messageHandler", messages); + setBoolean(transport, "replayingHandoff", true); + Field queueField = RedisBackendProxyTransport.class.getDeclaredField("deliveriesAfterReplay"); + queueField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.ArrayDeque queue = (java.util.ArrayDeque) queueField.get(transport); + for (int index = 0; index < RedisBackendProxyTransport.MAX_REPLAY_HANDOFF_DELIVERIES; index++) + queue.addLast(JsonEnvelope.builder("queued-" + index).build()); + JsonEnvelope newer = JsonEnvelope.builder("new").build(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch returned = new CountDownLatch(1); + Thread callback = new Thread(() -> { + entered.countDown(); + transport.dispatchIdentified(newer, "new-delivery"); + returned.countDown(); + }); + callback.start(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + assertFalse(returned.await(100, TimeUnit.MILLISECONDS), + "a full replay queue must apply backpressure instead of overtaking replay FIFO"); + verify(cache, never()).reserveRedisDelivery("new-delivery"); + + transport.close(); + assertTrue(returned.await(1, TimeUnit.SECONDS), "close must wake a blocked Redis callback"); + callback.join(TimeUnit.SECONDS.toMillis(1)); + + verify(cache, never()).reserveRedisDelivery("new-delivery"); + verifyNoInteractions(messages); + } + + @Test + void timedOutPubSubCallbackIsRetriedUntilReplayCapacityReturns() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery("received-delivery")).thenReturn(true); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(null, cache); + setField(transport, "messageHandler", mock(GlobalMessageHandler.class)); + setBoolean(transport, "replayingHandoff", true); + java.util.ArrayDeque queue = replayQueue(transport); + for (int index = 0; index < RedisBackendProxyTransport.MAX_REPLAY_HANDOFF_DELIVERIES; index++) + queue.addLast(JsonEnvelope.builder("queued-" + index).build()); + JsonEnvelope received = JsonEnvelope.builder("received").build(); + CountDownLatch completed = new CountDownLatch(1); + Thread subscriber = new Thread(() -> { + transport.dispatchReceivedSubscriberEnvelope(received, "received-delivery"); + completed.countDown(); + }); + subscriber.start(); + // The first bounded wait must time out without returning the consumed + // Pub/Sub payload to Redis (which would lose it); the callback retries it. + Thread.sleep(TimeUnit.SECONDS.toMillis(3) + 200L); + verify(cache, never()).reserveRedisDelivery("received-delivery"); + Object lifecycle = lifecycle(transport); + synchronized (lifecycle) { + queue.removeFirst(); + lifecycle.notifyAll(); + } + + assertTrue(completed.await(1, TimeUnit.SECONDS)); + subscriber.join(TimeUnit.SECONDS.toMillis(1)); + verify(cache).reserveRedisDelivery("received-delivery"); + assertEquals(received, queue.getLast(), "the received payload must retain FIFO replay ownership"); + transport.close(); + } + + @Test + void prePublicationRollbackDetachesPromotedReplayInArrivalOrder() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery("before-publication")).thenReturn(true); + when(cache.reserveRedisDelivery("during-publication")).thenReturn(true); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport replacement = new RedisBackendProxyTransport(null, cache); + setBoolean(replacement, "standbySubscriber", true); + setField(replacement, "messageHandler", mock(GlobalMessageHandler.class)); + JsonEnvelope before = JsonEnvelope.builder("before").build(); + JsonEnvelope during = JsonEnvelope.builder("during").build(); + + replacement.dispatchIdentified(before, "before-publication"); + replacement.activateAfterHandoff(); + // This is the narrow interval after worker-side promotion but before Bukkit + // opens publication/replay. It has already reserved its Redis identity. + replacement.dispatchIdentified(during, "during-publication"); + + assertEquals(java.util.List.of(before, during), replacement.detachReplayForFailedHandoff()); + assertTrue(replayQueue(replacement).isEmpty()); + verify(cache).reserveRedisDelivery("during-publication"); + } + + @Test + void consecutiveRedisHandoffTransfersActiveReplayBeforeNewStandbyArrivals() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery("new-overlap")).thenReturn(true); + RedisBackendProxyTransport previous = new RedisBackendProxyTransport(null, cache); + JsonEnvelope first = JsonEnvelope.builder("first").build(); + JsonEnvelope second = JsonEnvelope.builder("second").build(); + replayQueue(previous).addLast(first); + replayQueue(previous).addLast(second); + setBoolean(previous, "replayingHandoff", true); + + java.util.List transferred = previous.freezeReplayForSuccessiveHandoff(); + RedisBackendProxyTransport replacement = new RedisBackendProxyTransport(null, cache); + setBoolean(replacement, "standbySubscriber", true); + CopyOnWriteArrayList delivered = new CopyOnWriteArrayList<>(); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + delivered.add(invocation.getArgument(0)); + return null; + }).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + setField(replacement, "messageHandler", messages); + replacement.acceptReplayFromPreviousHandoff(transferred); + JsonEnvelope newer = JsonEnvelope.builder("newer").build(); + replacement.dispatchIdentified(newer, "new-overlap"); + replacement.activateAfterHandoff(); + replacement.replayAfterHandoffPublication(); + + assertEquals(java.util.List.of(first, second, newer), delivered, + "a later same-Redis handoff must preserve the active replay FIFO ahead of new overlap arrivals"); + } + + @Test + void callbackArrivingDuringFrozenTransferIsReleasedIntoRollbackReplay() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.reserveRedisDelivery("during-freeze")).thenReturn(true); + RedisBackendProxyTransport previous = new RedisBackendProxyTransport(null, cache); + JsonEnvelope inherited = JsonEnvelope.builder("inherited").build(); + replayQueue(previous).addLast(inherited); + setBoolean(previous, "replayingHandoff", true); + java.util.List transferred = previous.freezeReplayForSuccessiveHandoff(); + JsonEnvelope duringFreeze = JsonEnvelope.builder("during-freeze").build(); + CountDownLatch completed = new CountDownLatch(1); + Thread callback = new Thread(() -> { + previous.dispatchIdentified(duringFreeze, "during-freeze"); + completed.countDown(); + }); + callback.start(); + assertFalse(completed.await(100, TimeUnit.MILLISECONDS), + "the old subscriber must retain a consumed callback until transfer resolves"); + + previous.restoreFrozenReplayAfterFailedSuccessiveHandoff(transferred); + assertTrue(completed.await(1, TimeUnit.SECONDS)); + callback.join(TimeUnit.SECONDS.toMillis(1)); + assertEquals(java.util.List.of(inherited, duringFreeze), new java.util.ArrayList<>(replayQueue(previous))); + verify(cache).reserveRedisDelivery("during-freeze"); + } + + @Test + void successfulFrozenHandoffReleasesBlockedCallbackBeforeListenerClose() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + RedisBackendProxyTransport previous = new RedisBackendProxyTransport(null, cache); + setField(previous, "messageHandler", mock(GlobalMessageHandler.class)); + replayQueue(previous).addLast(JsonEnvelope.builder("inherited").build()); + setBoolean(previous, "replayingHandoff", true); + previous.freezeReplayForSuccessiveHandoff(); + CountDownLatch callbackDone = new CountDownLatch(1); + Thread callback = new Thread(() -> { + previous.dispatchIdentified(JsonEnvelope.builder("during-close").build(), "during-close"); + callbackDone.countDown(); + }); + callback.start(); + assertFalse(callbackDone.await(100, TimeUnit.MILLISECONDS)); + + Thread close = new Thread(previous::closeForHandoff); + close.start(); + close.join(TimeUnit.SECONDS.toMillis(1)); + assertFalse(close.isAlive(), "listener close must not wait for the frozen callback timeout"); + assertTrue(callbackDone.await(1, TimeUnit.SECONDS)); + callback.join(TimeUnit.SECONDS.toMillis(1)); + verify(cache, never()).reserveRedisDelivery("during-close"); + } + + @Test + void redisToNonRedisTransitionDefersUntilActiveReplayIsEmpty() throws Exception { + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + RedisBackendProxyTransport redis = new RedisBackendProxyTransport(null, cache); + JsonEnvelope pending = JsonEnvelope.builder("pending-replay").build(); + replayQueue(redis).addLast(pending); + setBoolean(redis, "replayingHandoff", true); + BackendProxyTransportManager manager = new BackendProxyTransportManager(null, cache); + setField(manager, "transport", redis); + + assertFalse(manager.prepareRedisReplayTransition(BungeeMethod.HTTP, System.nanoTime()), + "Redis-to-HTTP must defer rather than clearing active Redis replay"); + assertFalse(manager.prepareRedisReplayTransition(BungeeMethod.PLUGINMESSAGING, System.nanoTime()), + "Redis-to-plugin-messaging must apply the same replay ownership gate"); + assertEquals(java.util.List.of(pending), new java.util.ArrayList<>(replayQueue(redis))); + + setBoolean(redis, "replayingHandoff", false); + replayQueue(redis).clear(); + assertTrue(manager.prepareRedisReplayTransition(BungeeMethod.HTTP, + System.nanoTime() + TimeUnit.SECONDS.toNanos(1))); + assertTrue(manager.prepareRedisReplayTransition(BungeeMethod.PLUGINMESSAGING, + System.nanoTime() + TimeUnit.SECONDS.toNanos(1))); + JsonEnvelope duringCutover = JsonEnvelope.builder("during-cutover").build(); + manager.send(duringCutover); + assertEquals(java.util.List.of(duringCutover), new java.util.ArrayList<>(preparedSendQueue(manager)), + "outbound sends must be fenced until publication can transfer them to the replacement"); + } + + @Test + void retiredRedisCleanupDoesNotBlockManagerCloseOnPublicationThread() throws Exception { + BackendProxyTransportManager manager = new BackendProxyTransportManager(null, new ProcessedVoteCache()); + RedisBackendProxyTransport retired = mock(RedisBackendProxyTransport.class); + CountDownLatch closeEntered = new CountDownLatch(1); + CountDownLatch releaseClose = new CountDownLatch(1); + doAnswer(invocation -> { + closeEntered.countDown(); + releaseClose.await(1, TimeUnit.SECONDS); + return null; + }).when(retired).close(); + setField(manager, "retiredTransport", retired); + + long started = System.nanoTime(); + manager.close(); + long elapsedMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + + assertTrue(elapsedMillis < 100L, + "publication-facing manager close must not join a retired Redis listener"); + assertTrue(closeEntered.await(1, TimeUnit.SECONDS)); + releaseClose.countDown(); + } + + @Test + void replayBatchIsRequeuedWhenBukkitSchedulingRejectsIt() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + CountDownLatch rejected = new CountDownLatch(1); + doAnswer(invocation -> { + rejected.countDown(); + throw new IllegalStateException("scheduler stopped"); + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), + org.mockito.ArgumentMatchers.any(Runnable.class)); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache); + setBoolean(transport, "standbySubscriber", true); + setField(transport, "messageHandler", mock(GlobalMessageHandler.class)); + transport.dispatchLegacy(JsonEnvelope.builder("replay").build()); + transport.activateAfterHandoff(); + transport.replayAfterHandoffPublication(); + + assertTrue(rejected.await(1, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (replayTrackedDeliveries(transport) != 1 && System.nanoTime() < deadline) + Thread.sleep(10L); + assertEquals(1, replayTrackedDeliveries(transport), + "a rejected Bukkit task must return its removed batch to replay ownership"); + transport.close(); + } + + @Test + void interruptedReplayWorkerReturnsItsUnstartedBatch() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + CountDownLatch submitted = new CountDownLatch(1); + doAnswer(invocation -> { + submitted.countDown(); + return null; // emulate a Bukkit task that has not run yet + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), + org.mockito.ArgumentMatchers.any(Runnable.class)); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache); + setBoolean(transport, "standbySubscriber", true); + setField(transport, "messageHandler", mock(GlobalMessageHandler.class)); + transport.dispatchLegacy(JsonEnvelope.builder("replay").build()); + transport.activateAfterHandoff(); + transport.replayAfterHandoffPublication(); + assertTrue(submitted.await(1, TimeUnit.SECONDS)); + + Field workerField = RedisBackendProxyTransport.class.getDeclaredField("replayWorker"); + workerField.setAccessible(true); + Thread worker = (Thread) workerField.get(transport); + assertTrue(worker != null); + worker.interrupt(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (replayTrackedDeliveries(transport) != 1 && System.nanoTime() < deadline) + Thread.sleep(10L); + assertEquals(1, replayTrackedDeliveries(transport), + "interrupting a worker before Bukkit executes must not lose its batch"); + transport.close(); + } + + @Test + void closeFencesAlreadyScheduledReplayAfterItsCurrentBukkitCallback() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(invocation -> { + ((Runnable) invocation.getArgument(1)).run(); + return null; + }).when(scheduler).runTask(org.mockito.ArgumentMatchers.same(plugin), + org.mockito.ArgumentMatchers.any(Runnable.class)); + ProcessedVoteCache cache = mock(ProcessedVoteCache.class); + when(cache.consumeLegacyRedisDelivery(org.mockito.ArgumentMatchers.anyString())).thenReturn(false); + RedisBackendProxyTransport transport = new RedisBackendProxyTransport(plugin, cache); + setBoolean(transport, "standbySubscriber", true); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + GlobalMessageHandler messages = mock(GlobalMessageHandler.class); + doAnswer(invocation -> { + entered.countDown(); + release.await(1, TimeUnit.SECONDS); + return null; + }).when(messages).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + setField(transport, "messageHandler", messages); + transport.dispatchLegacy(JsonEnvelope.builder("first").build()); + transport.dispatchLegacy(JsonEnvelope.builder("second").build()); + transport.activateAfterHandoff(); + transport.replayAfterHandoffPublication(); + assertTrue(entered.await(1, TimeUnit.SECONDS)); + + transport.close(); + release.countDown(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (replayCallbacksInFlight(transport) != 0 && System.nanoTime() < deadline) + Thread.sleep(10L); + assertEquals(0, replayCallbacksInFlight(transport), + "the executing callback must remain accounted for after its generation is cancelled"); + verify(messages, times(1)).onMessage(org.mockito.ArgumentMatchers.any(JsonEnvelope.class)); + } + + private static void setBoolean(Object target, String name, boolean value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.setBoolean(target, value); + } + + private static boolean getBoolean(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.getBoolean(target); + } + + private static void setField(Object target, String name, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + @SuppressWarnings("unchecked") + private static java.util.ArrayDeque preparedSendQueue(BackendProxyTransportManager manager) + throws Exception { + Field field = BackendProxyTransportManager.class.getDeclaredField("preparedSends"); + field.setAccessible(true); + return (java.util.ArrayDeque) field.get(manager); + } + + private static int replayTrackedDeliveries(RedisBackendProxyTransport transport) throws Exception { + java.util.ArrayDeque queue = replayQueue(transport); + Field inFlightField = RedisBackendProxyTransport.class.getDeclaredField("replayDeliveriesInFlight"); + inFlightField.setAccessible(true); + Object lifecycle = lifecycle(transport); + synchronized (lifecycle) { + return queue.size() + inFlightField.getInt(transport); + } + } + + @SuppressWarnings("unchecked") + private static java.util.ArrayDeque replayQueue(RedisBackendProxyTransport transport) throws Exception { + Field queueField = RedisBackendProxyTransport.class.getDeclaredField("deliveriesAfterReplay"); + queueField.setAccessible(true); + return (java.util.ArrayDeque) queueField.get(transport); + } + + private static Object lifecycle(RedisBackendProxyTransport transport) throws Exception { + Field lifecycleField = RedisBackendProxyTransport.class.getDeclaredField("legacyLifecycle"); + lifecycleField.setAccessible(true); + return lifecycleField.get(transport); + } + + private static int replayCallbacksInFlight(RedisBackendProxyTransport transport) throws Exception { + Field callbacksField = RedisBackendProxyTransport.class.getDeclaredField("replayCallbacksInFlight"); + callbacksField.setAccessible(true); + Object lifecycle = lifecycle(transport); + synchronized (lifecycle) { + return callbacksField.getInt(transport); + } + } + + private static boolean awaitRetiredAfterHandoff(RedisBackendProxyTransport transport) throws Exception { + Field lifecycleField = RedisBackendProxyTransport.class.getDeclaredField("legacyLifecycle"); + lifecycleField.setAccessible(true); + Object lifecycle = lifecycleField.get(transport); + Field retiredField = RedisBackendProxyTransport.class.getDeclaredField("retiredAfterHandoff"); + retiredField.setAccessible(true); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(1); + while (System.nanoTime() < deadline) { + synchronized (lifecycle) { + if (retiredField.getBoolean(transport)) return true; + } + Thread.sleep(10L); + } + return false; + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java index babff77a7..d0825ccf3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -897,6 +897,41 @@ class BackendConfigurationServiceTest { () -> service.readQuickSetup("vote-party", Map.of("enabled", "not-a-boolean"))); } + @Test void votePartyEnabledOnlyChangesWhenExplicitlyEdited() throws Exception { + Path rewards = directory.resolve("SpecialRewards.yml"); + Files.writeString(rewards, "VoteParty:\n Enabled: false\n VotesRequired: 20\n" + + " GiveAllPlayers: false\n GiveOnlinePlayersOnly: true\n Broadcast: Keep\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickState disabled = service.readQuickSetup("vote-party", Map.of()); + assertEquals("false", disabled.options().get("enabled")); + BackendConfigurationService.QuickPreview unrelated = service.previewQuickSetup("vote-party", Map.of( + "enabled", disabled.options().get("enabled"), "votesRequired", "25", "command", "", + "broadcast", "Keep", "giveAllPlayers", "false", "onlineOnly", "true")); + assertTrue(unrelated.proposal().content().contains("Enabled: false")); + service.applyQuickSetup("vote-party", Map.of("enabled", "false", "votesRequired", "25", "command", "", + "broadcast", "Keep", "giveAllPlayers", "false", "onlineOnly", "true"), unrelated.revision()); + assertEquals("false", service.readQuickSetup("vote-party", Map.of()).options().get("enabled")); + + BackendConfigurationService.QuickPreview enable = service.previewQuickSetup("vote-party", Map.of( + "enabled", "true", "votesRequired", "25", "command", "", "broadcast", "Keep", + "giveAllPlayers", "false", "onlineOnly", "true")); + service.applyQuickSetup("vote-party", Map.of("enabled", "true", "votesRequired", "25", "command", "", + "broadcast", "Keep", "giveAllPlayers", "false", "onlineOnly", "true"), enable.revision()); + assertEquals("true", service.readQuickSetup("vote-party", Map.of()).options().get("enabled")); + } + + @Test void legacyVotePartyQuickSetupPreservesEnabledWhenOmitted() throws Exception { + Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.QuickPreview preview = service.previewQuickSetup("vote-party", Map.of( + "votesRequired", "25", "command", "", "broadcast", "", "giveAllPlayers", "false", + "onlineOnly", "true")); + + assertTrue(preview.proposal().content().contains("Enabled: false")); + } + @Test void oversizedInstalledGuidedValuesFailInsteadOfWedgingResultSubmission() throws Exception { Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Broadcast: '" + "é".repeat(251) + "'\n"); @@ -1078,6 +1113,62 @@ class BackendConfigurationServiceTest { BackendConfigurationService.QuickPreview mqtt = service.previewQuickSetup("proxy-backend", Map.of("server", "lobby", "method", "mqtt")); assertTrue(mqtt.proposal().content().contains("BungeeMethod: MQTT")); + BackendConfigurationService.QuickPreview http = service.previewQuickSetup("proxy-backend", + Map.of("server", "lobby", "method", "http")); + assertTrue(http.proposal().content().contains("BungeeMethod: HTTP")); + } + + @Test void httpConnectionCodeIsRedactedFromManagedConfiguration() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "HTTP:\n ConnectionCode: VPH1-sensitive-enrollment-code\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + + BackendConfigurationService.Document read = service.read("BungeeSettings.yml"); + assertFalse(read.content().contains("VPH1-sensitive-enrollment-code")); + assertTrue(read.content().contains(BackendConfigurationService.REDACTED)); + } + + @Test void httpMethodAcceptsGenerationBasedEnrolledProfile() throws Exception { + Files.writeString(directory.resolve("BungeeSettings.yml"), + "UseBungeecord: true\nServer: lobby-1\nBungeeMethod: PLUGINMESSAGING\nPluginMessageChannel: vp:vp\n"); + com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity identity = + com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity.loadOrCreate(directory.resolve("proxy"), "localhost"); + com.bencodez.simpleapi.servercomm.http.HttpConnectionCode code = + new com.bencodez.simpleapi.servercomm.http.HttpConnectionCode("lobby-1", + java.net.URI.create("https://localhost:1297/"), identity.serverCertificatePin(), + identity.caCertificatePin(), java.time.Instant.now().plusSeconds(60), "A".repeat(43)); + com.bencodez.simpleapi.servercomm.http.HttpClientCredentialStore.saveEnrolled(directory.resolve("http"), code, + identity.issueClientCertificate("lobby-1")); + + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + assertDoesNotThrow(() -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); + } + + @Test void httpMethodPreflightRejectsInvalidExpiredAndWrongServerConnectionCodes() throws Exception { + Path settings = directory.resolve("BungeeSettings.yml"); + Files.writeString(settings, "UseBungeecord: true\nServer: lobby-1\nBungeeMethod: PLUGINMESSAGING\n" + + "PluginMessageChannel: vp:vp\nHTTP:\n ConnectionCode: malformed\n"); + BackendConfigurationService service = new BackendConfigurationService(directory, () -> { }); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); + + com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity identity = + com.bencodez.simpleapi.servercomm.http.HttpTlsIdentity.loadOrCreate(directory.resolve("code-proxy"), "localhost"); + com.bencodez.simpleapi.servercomm.http.HttpConnectionCode expired = + new com.bencodez.simpleapi.servercomm.http.HttpConnectionCode("lobby-1", + java.net.URI.create("https://localhost:1297/"), identity.serverCertificatePin(), + identity.caCertificatePin(), java.time.Instant.now().minusSeconds(1), "A".repeat(43)); + Files.writeString(settings, Files.readString(settings).replace("malformed", expired.encode())); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); + + com.bencodez.simpleapi.servercomm.http.HttpConnectionCode wrongServer = + new com.bencodez.simpleapi.servercomm.http.HttpConnectionCode("survival", + expired.endpoint(), expired.serverCertificatePin(), expired.caCertificatePin(), + java.time.Instant.now().plusSeconds(60), "B".repeat(43)); + Files.writeString(settings, Files.readString(settings).replace(expired.encode(), wrongServer.encode())); + assertThrows(IllegalArgumentException.class, + () -> service.previewQuickSetup("proxy-method", Map.of("method", "HTTP"))); } @Test void proxyMethodSwitchPreflightsRequiredBackendSettings() throws Exception { @@ -1167,6 +1258,7 @@ class BackendConfigurationServiceTest { })); assertTrue(failure.rolledBack()); + assertTrue(failure.reloadAttempted()); assertEquals(2, targeted.get()); assertEquals(original, Files.readString(settings)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java index 11c607db9..8509f9e7f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -41,6 +41,22 @@ class BackendControlConnectorProtocolTest { "{\"error\":{\"code\":\"NODE_NOT_FOUND\"}}"))); } + @Test void deploymentTaskRejectsUnknownV1Fields() { + JsonObject task = deploymentTask(); + task.addProperty("unexpected", "value"); + assertThrows(IllegalArgumentException.class, () -> BackendControlConnector.deploymentTask(task)); + } + + private static JsonObject deploymentTask() { + JsonObject task = new JsonObject(); + task.addProperty("deploymentId", "00000000-0000-0000-0000-000000000001"); + task.addProperty("artifactId", "VotingPlugin.jar"); + task.addProperty("sha256", "a".repeat(64)); + task.addProperty("size", "1"); + task.addProperty("attemptId", "00000000-0000-0000-0000-000000000002"); + return task; + } + @Test void abandonedBackendIntentBecomesATerminalRecoveryResult() { JsonObject anticipated = new JsonObject(); anticipated.addProperty("attemptId", "00000000-0000-0000-0000-000000000199"); @@ -207,7 +223,7 @@ class BackendControlConnectorProtocolTest { @Test void registrationAdvertisesCommentPreservingFilesAsAnOptionalCapability() { JsonObject registration = new JsonObject(); - BackendControlConnector.addCapabilities(registration, true); + BackendControlConnector.addCapabilities(registration, true, false); JsonArray advertised = registration.getAsJsonArray("capabilities"); assertTrue(advertised.asList().stream() @@ -229,13 +245,29 @@ class BackendControlConnectorProtocolTest { .anyMatch(value -> "data.inspect.v1".equals(value.getAsString()))); } + @Test void deploymentCapabilityIsOnlyAddedWhenStagingWasPrepared() { + JsonObject unavailable = new JsonObject(); + BackendControlConnector.addCapabilities(unavailable, true, false); + assertFalse(unavailable.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); + + JsonObject ready = new JsonObject(); + BackendControlConnector.addCapabilities(ready, true, true); + assertTrue(ready.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); + assertTrue(ready.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> "config.reward-files.v1".equals(value.getAsString()))); + } + @Test void unsupportedFilesystemDoesNotAdvertiseNamedRewards() { JsonObject registration = new JsonObject(); - BackendControlConnector.addCapabilities(registration, false); + BackendControlConnector.addCapabilities(registration, false, true); assertFalse(registration.getAsJsonArray("capabilities").asList().stream() .anyMatch(value -> "config.reward-files.v1".equals(value.getAsString()))); assertTrue(registration.getAsJsonArray("capabilities").asList().stream() .anyMatch(value -> "config.files.v1".equals(value.getAsString()))); + assertTrue(registration.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> PluginDeploymentService.CAPABILITY.equals(value.getAsString()))); } @Test void heartbeatRetainsOmittedCapabilitiesAndHonorsExplicitReplacement() { @@ -266,6 +298,35 @@ class BackendControlConnectorProtocolTest { assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false, false)); } + @Test void httpProxyMethodApplyRequiresNegotiatedV2Capability() { + assertFalse(BackendControlConnector.proxyMethodApplyCapabilityAccepted("HTTP", false)); + assertTrue(BackendControlConnector.proxyMethodApplyCapabilityAccepted("HTTP", true)); + assertTrue(BackendControlConnector.proxyMethodApplyCapabilityAccepted("PLUGINMESSAGING", false)); + } + + @Test void httpQuickRequestsRejectMissingV2BeforeTouchingConfiguration() throws Exception { + BackendControlConnector connector = org.mockito.Mockito.mock(BackendControlConnector.class, + org.mockito.Mockito.CALLS_REAL_METHODS); + var quick = BackendControlConnector.class.getDeclaredField("quickSetupsAccepted"); + quick.setAccessible(true); + quick.setBoolean(connector, true); + JsonObject configuration = JsonParser.parseString( + "{\"preset\":\"proxy-method\",\"options\":{\"method\":\"HTTP\"}}") + .getAsJsonObject(); + var execute = BackendControlConnector.class.getDeclaredMethod("executeQuick", java.util.UUID.class, + String.class, JsonObject.class, JsonObject.class); + execute.setAccessible(true); + for (String type : new String[] { "READ", "PREVIEW", "APPLY" }) { + Object result = execute.invoke(connector, java.util.UUID.randomUUID(), type, configuration, + new JsonObject()); + var json = result.getClass().getDeclaredMethod("json"); + json.setAccessible(true); + JsonObject response = (JsonObject) json.invoke(result); + assertFalse(response.get("success").getAsBoolean()); + assertEquals("UNSUPPORTED_TASK", response.get("code").getAsString()); + } + } + @Test void votePartyRequiresItsVersionedCapability() { assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false)); assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false)); @@ -301,16 +362,17 @@ class BackendControlConnectorProtocolTest { IllegalStateException failure = new IllegalStateException( "/srv/private/VoteSites.yml jdbc:mysql://database.internal user=secret"); - assertEquals("Configuration read failed; see the backend log", + assertEquals("Configuration file is unavailable or unreadable", BackendControlConnector.operationFailureMessage("READ", failure)); - assertEquals("Configuration preview failed; see the backend log", + assertEquals("The backend could not prepare a configuration preview", BackendControlConnector.operationFailureMessage("PREVIEW", failure)); - assertEquals("Configuration apply failed; see the backend log", + assertEquals("The backend could not apply the managed configuration", BackendControlConnector.operationFailureMessage("APPLY", failure)); - assertEquals("Configuration reload failed; see the backend log", - BackendControlConnector.reloadFailureMessage(failure)); assertFalse(BackendControlConnector.operationFailureMessage("READ", failure).contains("/srv")); - assertFalse(BackendControlConnector.reloadFailureMessage(failure).contains("secret")); + assertEquals("CONFIGURATION_MISSING", BackendControlConnector.operationFailureCode("READ", + new java.nio.file.NoSuchFileException("/srv/private/Config.yml"))); + assertEquals("CONFIGURATION_UNREADABLE", BackendControlConnector.operationFailureCode("READ", + new java.nio.file.AccessDeniedException("/srv/private/Config.yml"))); } @Test void unavailableConfigurationReadsPreserveV1CodeAndUseASafeReason() { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java new file mode 100644 index 000000000..423e46db8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/PluginDeploymentServiceTest.java @@ -0,0 +1,379 @@ +package com.bencodez.votingplugin.control; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.Arrays; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.FileSystems; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.HexFormat; +import java.util.UUID; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class PluginDeploymentServiceTest { + @TempDir Path directory; + + @Test void publicationRefusesAProviderWithoutAtomicMoves() throws Exception { + Path source = directory.resolve("VotingPlugin.jar"); + byte[] original = jar("name: VotingPlugin\nversion: old\n"); + Files.write(source, original); + Path archive = directory.resolve("non-atomic.zip"); + try (var zip = FileSystems.newFileSystem(java.net.URI.create("jar:" + archive.toUri()), + java.util.Map.of("create", "true"))) { + Path destination = zip.getPath("/VotingPlugin.jar"); + assertThrows(AtomicMoveNotSupportedException.class, + () -> PluginDeploymentService.move(source, destination)); + assertArrayEquals(original, Files.readAllBytes(source)); + assertFalse(Files.exists(destination)); + } + } + + @Test void backendStagesOnlyAnExactVerifiedVotingPluginJarAndIsIdempotent() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nmain: example.Main\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); + PluginDeploymentService.Task task = task(artifact); + + PluginDeploymentService.Result staged = service.stage(task, new ByteArrayInputStream(artifact), () -> true); + + assertTrue(staged.success()); + assertEquals("RESTART_REQUIRED", staged.code()); + assertEquals(artifact.length, Files.size(directory.resolve("update/VotingPlugin.jar"))); + assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); + } + + @Test void backendStagesUsingTheInstalledPluginJarFileName() throws Exception { + byte[] artifact = jar("name: VotingPlugin\n"); + Path update = directory.resolve("update"); + PluginDeploymentService service = PluginDeploymentService.backend(update, + directory.resolve("plugins/VotingPlugin-7.1.2.jar")); + + assertEquals("RESTART_REQUIRED", service.stage(task(artifact), new ByteArrayInputStream(artifact), () -> true).code()); + assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin-7.1.2.jar"))); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar"))); + } + + @Test void backendRejectsAnEmptyOrDisabledBukkitUpdateFolderThatResolvesToTheLoadedJar() { + assertThrows(IOException.class, + () -> PluginDeploymentService.backend(Path.of(""), Path.of("VotingPlugin.jar"))); + } + + @Test void backendMatchingMarkerSurvivesBukkitConsumingTheStagedJar() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); + Path update = directory.resolve("update"); + Path installed = directory.resolve("VotingPlugin.jar"); + Files.write(installed, jar("name: VotingPlugin\nversion: old\n")); + PluginDeploymentService service = PluginDeploymentService.backend(update, installed); + PluginDeploymentService.Task task = task(artifact); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + Files.write(installed, artifact); + Files.delete(update.resolve("VotingPlugin.jar")); + + assertTrue(service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).success()); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar")), + "a lost acknowledgement after restart must recognize the artifact Bukkit already consumed"); + } + + @Test void retryWithNewDeploymentIdAcknowledgesOnlyTheSameVerifiedArtifact() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); + Path update = directory.resolve("update"); + Path installed = directory.resolve("VotingPlugin.jar"); + Files.write(installed, jar("name: VotingPlugin\nversion: old\n")); + PluginDeploymentService service = PluginDeploymentService.backend(update, installed); + PluginDeploymentService.Task original = task(artifact); + PluginDeploymentService.Task retry = task(artifact); + assertTrue(service.stage(original, new ByteArrayInputStream(artifact), () -> true).success()); + assertFalse(original.deploymentId().equals(retry.deploymentId())); + + assertEquals("RESTART_REQUIRED", + service.stage(retry, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin.jar"))); + + Files.write(installed, artifact); + Files.delete(update.resolve("VotingPlugin.jar")); + assertEquals("RESTART_REQUIRED", + service.stage(retry, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar"))); + } + + @Test void backendRestagesWhenTheUpdateJarDisappearsBeforeBukkitConsumesIt() throws Exception { + byte[] artifact = jar("name: VotingPlugin\nversion: candidate\n"); + Path update = directory.resolve("update"); + Path installed = directory.resolve("VotingPlugin.jar"); + Files.write(installed, jar("name: VotingPlugin\nversion: old\n")); + PluginDeploymentService service = PluginDeploymentService.backend(update, installed); + PluginDeploymentService.Task task = task(artifact); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + Files.delete(update.resolve("VotingPlugin.jar")); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + assertArrayEquals(artifact, Files.readAllBytes(update.resolve("VotingPlugin.jar")), + "a deleted or quarantined update must be staged again before restart"); + } + + @Test void credentialedDeploymentRequiresHttpsUnlessSameNodeHostedHttpWasProven() { + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("https://control.example.test"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://192.0.2.10:8080"), false)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.0.0.1:8080"), false)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.0.0.1:8080"), true)); + assertTrue(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://[::1]:8080"), true)); + assertFalse(PluginDeploymentService.credentialEndpointAllowed( + java.net.URI.create("http://127.example.com:8080"), true)); + } + + @Test void backendIgnoresMatchingMarkerOnlyWhenTargetIsValidThenRestagesWhenCorrupted() throws Exception { + byte[] artifact = jar("name: VotingPlugin\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); + PluginDeploymentService.Task task = task(artifact); + + service.stage(task, new ByteArrayInputStream(artifact), () -> true); + assertArrayEquals(artifact, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + + byte[] corrupted = new byte[artifact.length]; + Arrays.fill(corrupted, (byte) 0x42); + Files.write(directory.resolve("update/VotingPlugin.jar"), corrupted); + assertArrayEquals(corrupted, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + + assertTrue(service.stage(task, new ByteArrayInputStream(artifact), () -> true).success()); + assertArrayEquals(artifact, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + } + + @Test void invalidPluginIdentityAndDigestNeverReachTheUpdateFolder() throws Exception { + byte[] wrongPlugin = jar("name: NotVotingPlugin\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); + PluginDeploymentService.Result invalid = service.stage(task(wrongPlugin), new ByteArrayInputStream(wrongPlugin), () -> true); + assertFalse(invalid.success()); + assertEquals("INVALID_ARTIFACT", invalid.code()); + assertFalse(Files.exists(directory.resolve("update/VotingPlugin.jar"))); + + byte[] good = jar("name: VotingPlugin\n"); + PluginDeploymentService.Task mismatch = new PluginDeploymentService.Task(UUID.randomUUID(), "vp.jar", + "0".repeat(64), good.length, UUID.randomUUID()); + assertEquals("HASH_MISMATCH", service.stage(mismatch, new ByteArrayInputStream(good), () -> true).code()); + } + + @Test void proxyKeepsTheCurrentJarAsABackupBeforeReplacement() throws Exception { + Path current = directory.resolve("VotingPlugin.jar"); + byte[] original = jar("name: VotingPlugin\nversion: 0\n"); + byte[] first = jar("name: VotingPlugin\nversion: 1\n"); + byte[] second = jar("name: VotingPlugin\nversion: 2\n"); + Files.write(current, original); + PluginDeploymentService service = PluginDeploymentService.proxy(current); + + assertEquals("RESTART_REQUIRED", service.stage(task(first), new ByteArrayInputStream(first), () -> true).code()); + assertEquals("RESTART_REQUIRED", service.stage(task(second), new ByteArrayInputStream(second), () -> true).code()); + assertArrayEquals(first, Files.readAllBytes(directory.resolve("VotingPlugin.jar.control-backup"))); + assertArrayEquals(second, Files.readAllBytes(current)); + } + + @Test void proxyMissingMarkerRequiresNormalVerifiedStaging() throws Exception { + Path current = directory.resolve("VotingPlugin.jar"); + Path backup = directory.resolve("VotingPlugin.jar.control-backup"); + Path marker = directory.resolve("VotingPlugin.jar.control-deployment"); + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + PluginDeploymentService.Task task = task(candidate); + Files.write(current, candidate); + Files.write(backup, original); + PluginDeploymentService service = PluginDeploymentService.proxy(current); + + assertEquals("SIZE_MISMATCH", + service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertFalse(Files.exists(marker)); + assertArrayEquals(original, Files.readAllBytes(backup)); + assertEquals("RESTART_REQUIRED", + service.stage(task, new ByteArrayInputStream(candidate), () -> true).code()); + + assertArrayEquals(candidate, Files.readAllBytes(current)); + assertArrayEquals(candidate, Files.readAllBytes(backup)); + String state = Files.readString(marker, StandardCharsets.US_ASCII); + assertTrue(state.contains(task.deploymentId().toString())); + assertTrue(state.contains(task.sha256())); + assertTrue(state.contains(Long.toString(task.size()))); + } + + @Test void proxyDoesNotAcknowledgeAStagedArtifactWithoutItsBackup() throws Exception { + Path current = directory.resolve("VotingPlugin.jar"); + Path backup = directory.resolve("VotingPlugin.jar.control-backup"); + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + Files.write(current, original); + PluginDeploymentService service = PluginDeploymentService.proxy(current); + PluginDeploymentService.Task task = task(candidate); + assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(candidate), () -> true).code()); + Files.delete(backup); + assertEquals("SIZE_MISMATCH", service.stage(task, new ByteArrayInputStream(new byte[0]), () -> true).code()); + assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(candidate), () -> true).code()); + assertTrue(Files.isRegularFile(backup)); + } + + @Test void cancellationDuringCopyDoesNotPublish() throws Exception { + byte[] artifact = jar("name: VotingPlugin\n"); + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); + PluginDeploymentService.Task task = task(artifact); + + assertEquals("RESTART_REQUIRED", service.stage(task, new ByteArrayInputStream(artifact), () -> true).code()); + String markerBefore = Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment")); + byte[] targetBefore = Files.readAllBytes(directory.resolve("update/VotingPlugin.jar")); + + AtomicBoolean active = new AtomicBoolean(true); + byte[] replacementArtifact = jar("name: VotingPlugin\nversion: replacement\n"); + InputStream body = new CancellingInputStream(new ByteArrayInputStream(replacementArtifact), active); + PluginDeploymentService.Task replacement = task(replacementArtifact); + assertEquals("CANCELLED", service.stage(replacement, body, active::get).code()); + + assertEquals(markerBefore, Files.readString(directory.resolve("update/VotingPlugin.jar.control-deployment"))); + assertArrayEquals(targetBefore, Files.readAllBytes(directory.resolve("update/VotingPlugin.jar"))); + assertFalse(active.get()); + } + + @Test void markerPublicationFailureRestoresThePreviousArtifact() throws Exception { + Path update = directory.resolve("update"); + Files.createDirectories(update); + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + Path target = update.resolve("VotingPlugin.jar"); + Files.write(target, original); + Path marker = update.resolve("VotingPlugin.jar.control-deployment"); + Files.createDirectory(marker); + Files.writeString(marker.resolve("keep"), "marker publication must fail"); + PluginDeploymentService service = PluginDeploymentService.backend(update, Path.of("VotingPlugin.jar")); + + assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true)); + + assertArrayEquals(original, Files.readAllBytes(target)); + } + + @Test void proxyMarkerPublicationFailureRestoresTheCurrentJarAndKeepsItsBackup() throws Exception { + byte[] original = jar("name: VotingPlugin\nversion: original\n"); + byte[] candidate = jar("name: VotingPlugin\nversion: candidate\n"); + Path target = directory.resolve("VotingPlugin.jar"); + Files.write(target, original); + Path marker = directory.resolve("VotingPlugin.jar.control-deployment"); + Files.createDirectory(marker); + Files.writeString(marker.resolve("keep"), "marker publication must fail"); + PluginDeploymentService service = PluginDeploymentService.proxy(target); + + assertThrows(IOException.class, () -> service.stage(task(candidate), new ByteArrayInputStream(candidate), () -> true)); + + assertArrayEquals(original, Files.readAllBytes(target)); + assertArrayEquals(original, Files.readAllBytes(directory.resolve("VotingPlugin.jar.control-backup"))); + } + + @Test void cancellationClosesAStalledArtifactStream() throws Exception { + PluginDeploymentService service = PluginDeploymentService.backend(directory.resolve("update"), Path.of("VotingPlugin.jar")); + AtomicBoolean active = new AtomicBoolean(true); + BlockingInputStream stalled = new BlockingInputStream(active); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future result = executor.submit(() -> service.stage( + task(new byte[] { 1 }), stalled, active::get)); + assertTrue(stalled.awaitRead()); + service.cancel(); + assertEquals("CANCELLED", result.get(5, TimeUnit.SECONDS).code()); + assertFalse(Files.exists(directory.resolve("update/VotingPlugin.jar"))); + } finally { + executor.shutdownNow(); + } + } + + private static PluginDeploymentService.Task task(byte[] artifact) throws Exception { + String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(artifact)); + return new PluginDeploymentService.Task(UUID.randomUUID(), "votingplugin.jar", digest, artifact.length, + UUID.randomUUID()); + } + + private static byte[] jar(String pluginYml) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(bytes)) { + jar.putNextEntry(new JarEntry("plugin.yml")); + jar.write(pluginYml.getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + jar.putNextEntry(new JarEntry("example/Main.class")); + jar.write(new byte[] { 0, 1, 2 }); + jar.closeEntry(); + } + return bytes.toByteArray(); + } + + private static class CancellingInputStream extends InputStream { + private final InputStream delegate; + private final AtomicBoolean active; + private boolean cancelled; + + private CancellingInputStream(InputStream delegate, AtomicBoolean active) { + this.delegate = delegate; + this.active = active; + } + + @Override + public int read(byte[] bytes, int start, int length) throws IOException { + int read = delegate.read(bytes, start, length); + if (!cancelled && read > 0) { + cancelled = true; + active.set(false); + } + return read; + } + + @Override + public int read() throws IOException { return delegate.read(); } + } + + private static class BlockingInputStream extends InputStream { + private final AtomicBoolean active; + private final CountDownLatch reading = new CountDownLatch(1); + private boolean closed; + + private BlockingInputStream(AtomicBoolean active) { this.active = active; } + + private boolean awaitRead() throws InterruptedException { return reading.await(5, TimeUnit.SECONDS); } + + @Override + public synchronized int read(byte[] bytes, int start, int length) throws IOException { + reading.countDown(); + while (!closed) { + try { wait(); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IOException(failure); } + } + throw new IOException("stream closed"); + } + + @Override + public int read() throws IOException { return read(new byte[1], 0, 1); } + + @Override + public synchronized void close() { + closed = true; + active.set(false); + notifyAll(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyQueueProgressTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyQueueProgressTest.java new file mode 100644 index 000000000..c97a78ad3 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/VotingPluginProxyQueueProgressTest.java @@ -0,0 +1,61 @@ +package com.bencodez.votingplugin.proxy; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; +import java.util.Queue; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; +import com.bencodez.votingplugin.tests.VotingPluginProxyTestImpl; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; + +class VotingPluginProxyQueueProgressTest { + @Test + void nonblockingQueuedRetryAllowsLaterProcessedRowToDrain() { + VoteCacheHandler cache = Mockito.mock(VoteCacheHandler.class); + Queue queue = new ConcurrentLinkedQueue<>(); + VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L); + VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 101L); + second.setProcessed(true); + queue.add(first); + queue.add(second); + Mockito.when(cache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(cache.removeTimeVote(second)).thenAnswer(invocation -> queue.remove(second)); + VotingPluginProxy proxy = Mockito.spy(new VotingPluginProxyTestImpl()); + Mockito.doReturn(cache).when(proxy).getVoteCacheHandler(); + Mockito.doReturn(VotingPluginProxy.QueuedVoteResult.RETRY_NONBLOCKING) + .when(proxy).replayQueuedVote(Mockito.eq(first), Mockito.isNull(), Mockito.anyBoolean()); + + proxy.processQueue(); + + assertTrue(queue.contains(first)); + assertFalse(queue.contains(second)); + } + + @Test + void ordinaryQueuedRetryStillStopsBeforeLaterRow() { + VoteCacheHandler cache = Mockito.mock(VoteCacheHandler.class); + Queue queue = new ConcurrentLinkedQueue<>(); + VoteTimeQueue first = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L); + VoteTimeQueue second = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 101L); + second.setProcessed(true); + queue.add(first); + queue.add(second); + Mockito.when(cache.getTimeChangeQueue()).thenReturn(queue); + VotingPluginProxy proxy = Mockito.spy(new VotingPluginProxyTestImpl()); + Mockito.doReturn(cache).when(proxy).getVoteCacheHandler(); + Mockito.doReturn(VotingPluginProxy.QueuedVoteResult.RETRY) + .when(proxy).replayQueuedVote(Mockito.eq(first), Mockito.isNull(), Mockito.anyBoolean()); + + proxy.processQueue(); + + assertTrue(queue.contains(first)); + assertTrue(queue.contains(second)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/cache/VotePartyCacheDurabilityTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/cache/VotePartyCacheDurabilityTest.java new file mode 100644 index 000000000..7dcec7054 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/cache/VotePartyCacheDurabilityTest.java @@ -0,0 +1,61 @@ +package com.bencodez.votingplugin.proxy.cache; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VotePartyCacheDurabilityTest { + @TempDir Path directory; + + @Test + void detectsWhenCacheSaveReturnsWithoutUpdatingTheFile() throws Exception { + IVoteCache cache = mock(IVoteCache.class); + when(cache.getPendingVotePartyRewardServers()).thenReturn(Set.of("lobby")); + when(cache.getPendingVotePartyRewardIds("lobby")).thenReturn(Set.of("delivery-id")); + when(cache.getVotePartyCurrentVotes()).thenReturn(3); + when(cache.getVotePartyInreaseVotesRequired()).thenReturn(2); + when(cache.getPendingVotePartyProxyEffects()).thenReturn(PendingVotePartyProxyEffects.empty()); + when(cache.getQuarantinedVotePartyProxyEffects()).thenReturn(PendingVotePartyProxyEffects.empty()); + Path file = directory.resolve("votecache.json"); + Files.writeString(file, "{\"VoteParty\":{\"CurrentVotes\":3,\"IncreaseVotes\":2}}"); + + assertThrows(java.io.IOException.class, () -> VotePartyCacheDurability.saveAndVerify(file, cache)); + } + + @Test + void detectsWhenPendingProxyEffectsAreMissingFromTheFile() throws Exception { + IVoteCache cache = mock(IVoteCache.class); + when(cache.getPendingVotePartyRewardServers()).thenReturn(Set.of()); + when(cache.getVotePartyCurrentVotes()).thenReturn(3); + when(cache.getVotePartyInreaseVotesRequired()).thenReturn(2); + when(cache.getPendingVotePartyProxyEffects()) + .thenReturn(new PendingVotePartyProxyEffects("Party time", java.util.List.of("reward all"))); + when(cache.getQuarantinedVotePartyProxyEffects()).thenReturn(PendingVotePartyProxyEffects.empty()); + Path file = directory.resolve("votecache.json"); + Files.writeString(file, "{\"VoteParty\":{\"CurrentVotes\":3,\"IncreaseVotes\":2}}"); + + assertThrows(java.io.IOException.class, () -> VotePartyCacheDurability.saveAndVerify(file, cache)); + } + + @Test + void detectsWhenQuarantinedProxyEffectsAreMissingFromTheFile() throws Exception { + IVoteCache cache = mock(IVoteCache.class); + when(cache.getPendingVotePartyRewardServers()).thenReturn(Set.of()); + when(cache.getVotePartyCurrentVotes()).thenReturn(3); + when(cache.getVotePartyInreaseVotesRequired()).thenReturn(2); + when(cache.getPendingVotePartyProxyEffects()).thenReturn(PendingVotePartyProxyEffects.empty()); + when(cache.getQuarantinedVotePartyProxyEffects()) + .thenReturn(new PendingVotePartyProxyEffects("", java.util.List.of("uncertain reward"))); + Path file = directory.resolve("votecache.json"); + Files.writeString(file, "{\"VoteParty\":{\"CurrentVotes\":3,\"IncreaseVotes\":2}}"); + + assertThrows(java.io.IOException.class, () -> VotePartyCacheDurability.saveAndVerify(file, cache)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java index caffbbc47..124dae39c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ControlConnectorTest.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.proxy.control; +import com.bencodez.votingplugin.control.PluginDeploymentService; import com.bencodez.votingplugin.proxy.control.ControlConnector.ObservedBackend; import com.bencodez.votingplugin.proxy.control.ControlConnector.Request; import com.bencodez.votingplugin.proxy.control.ControlConnector.Response; @@ -12,6 +13,7 @@ import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.net.http.HttpClient; import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; @@ -23,6 +25,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -48,6 +51,17 @@ class ControlConnectorTest { @Test void responseBudgetCanCarryTheLargestEscapedManagedFileTask() { assertTrue(ControlConnector.MAX_RESPONSE_BYTES >= ProxyConfigurationFileService.MAX_BYTES * 6); } + + @Test void deploymentTaskRejectsUnknownV1Fields() { + JsonObject task = new JsonObject(); + task.addProperty("deploymentId", "00000000-0000-0000-0000-000000000001"); + task.addProperty("artifactId", "VotingPlugin.jar"); + task.addProperty("sha256", "a".repeat(64)); + task.addProperty("size", "1"); + task.addProperty("attemptId", "00000000-0000-0000-0000-000000000002"); + task.addProperty("unexpected", "value"); + assertThrows(RuntimeException.class, () -> ControlConnector.deploymentTask(task)); + } private ControlConnector connector; @BeforeEach void setUp() { @@ -88,6 +102,18 @@ class ControlConnectorTest { assertEquals(1, secondSnapshot.get("sequence").getAsLong()); } + @Test void deploymentCapabilityIsAdvertisedOnlyWhenProxyStagingIsReady() { + JsonObject unavailable = new JsonObject(); + ControlConnector.addCapabilities(unavailable, true, true, true, true, false); + assertFalse(unavailable.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> "plugin.deploy.v1".equals(value.getAsString()))); + + JsonObject ready = new JsonObject(); + ControlConnector.addCapabilities(ready, true, true, true, true, true); + assertTrue(ready.getAsJsonArray("capabilities").asList().stream() + .anyMatch(value -> "plugin.deploy.v1".equals(value.getAsString()))); + } + @Test void unavailableAuthenticationProtocolAndMalformedResponsesOnlyChangeConnectorState() { transport.nextPrimary = new Response(401, "{\"error\":{}}"); connector.cycle(); @@ -294,6 +320,31 @@ class ControlConnectorTest { assertFalse(ControlConnector.requiresRuntimeReplacement(new StoredResult(result, true, false))); } + @Test void lowercaseHttpProxyMethodRequiresV2ForEveryOperationPhase() throws Exception { + Field accepted = ControlConnector.class.getDeclaredField("acceptedCapabilities"); + accepted.setAccessible(true); + Method execute = ControlConnector.class.getDeclaredMethod("executeTask", UUID.class, JsonObject.class); + execute.setAccessible(true); + for (String method : List.of("http", " HTTP ")) { + for (String type : List.of("READ", "PREVIEW", "APPLY")) { + JsonObject task = JsonParser.parseString("{\"configuration\":{\"domain\":\"quick-setup\"," + + "\"preset\":\"proxy-method\",\"options\":{\"method\":\"" + method + "\"}}}") + .getAsJsonObject(); + task.addProperty("type", type); + accepted.set(connector, Set.of("config.proxy-method.v1")); + Object rejected = ((CompletableFuture) execute.invoke(connector, UUID.randomUUID(), task)).join(); + Method json = rejected.getClass().getDeclaredMethod("json"); + json.setAccessible(true); + assertTrue(((JsonObject) json.invoke(rejected)).get("message").getAsString() + .contains("not negotiated")); + accepted.set(connector, Set.of("config.proxy-method.v2")); + Object acceptedResult = ((CompletableFuture) execute.invoke(connector, UUID.randomUUID(), task)).join(); + assertTrue(((JsonObject) json.invoke(acceptedResult)).get("message").getAsString() + .contains("unavailable")); + } + } + } + @Test void proxyFileCapabilityAdvertisesAndDispatchesMaskedReadResults() throws Exception { Path file = dataDirectory.resolve(ProxyConfigurationFileService.FILE_NAME); Files.writeString(file, "Database:\n Password: local-secret\nProxy:\n Enabled: true\n"); @@ -879,6 +930,51 @@ class ControlConnectorTest { assertEquals(Status.STOPPED, connector.status()); } + @Test void closeCancelsARealQueuedDeploymentBeforeAwaitingItsOperation() throws Exception { + connector.close(); + Path update = dataDirectory.resolve("update"); + connector = deploymentConnector(PluginDeploymentService.backend(update, dataDirectory.resolve("VotingPlugin.jar"))); + setConnectorField("registered", true); + setConnectorField("deploymentsAccepted", true); + setConnectorField("status", Status.CONNECTED); + Field executorField = ControlConnector.class.getDeclaredField("deploymentExecutor"); + executorField.setAccessible(true); + ExecutorService deploymentExecutor = (ExecutorService) executorField.get(connector); + CountDownLatch workerStarted = new CountDownLatch(1); + deploymentExecutor.submit(() -> { + workerStarted.countDown(); + try { + new CountDownLatch(1).await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + }); + assertTrue(workerStarted.await(2, TimeUnit.SECONDS)); + UUID deploymentId = UUID.randomUUID(); + transport.nextPrimary = new Response(200, "{\"deploymentId\":\"" + deploymentId + + "\",\"artifactId\":\"VotingPlugin.jar\",\"sha256\":\"" + "a".repeat(64) + + "\",\"size\":\"1\",\"attemptId\":\"" + UUID.randomUUID() + "\"}"); + Method poll = ControlConnector.class.getDeclaredMethod("pollDeployments"); + poll.setAccessible(true); + poll.invoke(connector); + Field operationField = ControlConnector.class.getDeclaredField("activeOperation"); + operationField.setAccessible(true); + CompletableFuture operation = (CompletableFuture) operationField.get(connector); + assertNotNull(operation, "the actual deployment polling path must own the queued operation"); + + connector.close(); + + assertTrue(operation.isDone(), "shutdown must complete a deployment discarded before its executor starts it"); + assertFalse(Files.exists(update.resolve("VotingPlugin.jar")), "queued deployment work must not stage an artifact"); + } + + @Test void proxyDeploymentIsNotAdvertisedOnWindowsWhereTheLiveJarCannotBeReplaced() { + assertFalse(ControlConnector.proxyDeploymentSupported("Windows 11")); + assertFalse(ControlConnector.proxyDeploymentSupported("Windows Server 2022")); + assertTrue(ControlConnector.proxyDeploymentSupported("Linux")); + assertTrue(ControlConnector.proxyDeploymentSupported("Darwin")); + } + @Test void backoffIsBoundedExponentialAndJitteredWithoutSleeping() { assertEquals(1000, ControlConnector.backoffMillis(1, 0)); assertEquals(2000, ControlConnector.backoffMillis(2, 0)); @@ -909,6 +1005,25 @@ private Settings settings() { URI.create("http://127.0.0.1:8080"), 30, 3000, 5000); } + private ControlConnector deploymentConnector(PluginDeploymentService deployments) throws Exception { + Constructor constructor = ControlConnector.class.getDeclaredConstructor(Settings.class, + ScheduledExecutorService.class, Transport.class, Supplier.class, Consumer.class, UUID.class, + LongSupplier.class, ProxyRoutingConfigurationService.class, Path.class, ProxyControlResultStore.Route.class, + boolean.class, Runnable.class, Function.class, ProxyMethodConfigurationService.class, Runnable.class, + ProxyConfigurationFileService.class, PluginDeploymentService.class, HttpClient.class, String.class, + boolean.class); + constructor.setAccessible(true); + return constructor.newInstance(settings(), scheduler, transport, (Supplier>) List::of, + (Consumer) logs::add, UUID.randomUUID(), (LongSupplier) () -> 0L, null, null, null, false, + null, null, null, null, null, deployments, HttpClient.newHttpClient(), "credential", false); + } + + private void setConnectorField(String name, Object value) throws Exception { + Field field = ControlConnector.class.getDeclaredField(name); + field.setAccessible(true); + field.set(connector, value); + } + private JsonObject submittedResult() { return JsonParser.parseString(transport.requests.stream().filter(request -> request.path().endsWith("/result")) .findFirst().orElseThrow().body()).getAsJsonObject(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java index 4e6a25da7..2587c9996 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/proxy/control/ProxyMethodConfigurationServiceTest.java @@ -4,9 +4,13 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Set; @@ -59,6 +63,27 @@ void validatesRequiredSettingsForEveryTransport() { when(config.getSpigotServerConfiguration("lobby")).thenReturn(Map.of("Host", "localhost")); assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.SOCKETS))); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpHost()).thenReturn("0.0.0.0"); + when(config.getHttpPort()).thenReturn(1297); + when(config.getHttpPublicEndpoint()).thenReturn("http://proxy.example.test:1297"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:1297/terminated-path"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:0"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:65536"); + assertThrows(IllegalArgumentException.class, + () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + when(config.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:1297"); + assertDoesNotThrow(() -> service.validate(new ProxyMethodConfiguration(BungeeMethod.HTTP))); + assertThrows(IllegalArgumentException.class, () -> service.validate(new ProxyMethodConfiguration(BungeeMethod.MYSQL))); when(config.hasDatabaseConfigured()).thenReturn(true); @@ -121,4 +146,86 @@ void applyDoesNotRejectAValidFreshSnapshotUsingStaleCachedSettings() throws Exce ProxyMethodConfiguration proposal = new ProxyMethodConfiguration(BungeeMethod.REDIS); assertDoesNotThrow(() -> service.apply(proposal, service.read().revision())); } + + @Test + void preparesHttpListenerFromFreshSnapshotBeforePublishingMethod() throws Exception { + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + VotingPluginProxyConfig fresh = validHttpConfig(); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("HTTP"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + service.apply(new ProxyMethodConfiguration(BungeeMethod.HTTP), service.read().revision()); + + verify(proxy).prepareHttpTransportChange(fresh); + verify(config).verifyControlProxyRoutingInstalled(); + } + + @Test + void reusesRetainedMatchingHttpListenerWhenRevertingDeferredMethod() throws Exception { + when(config.getBungeeMethod()).thenReturn("REDIS"); + VotingPluginProxyConfig fresh = validHttpConfig(); + when(proxy.hasMatchingLiveHttpTransport(fresh)).thenReturn(true); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("HTTP"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + service.apply(new ProxyMethodConfiguration(BungeeMethod.HTTP), service.read().revision()); + + verify(proxy, never()).prepareHttpTransportChange(fresh); + verify(config).verifyControlProxyRoutingInstalled(); + } + + @Test + void closesPreparedHttpListenerWhenPublicationVerificationFails() throws Exception { + when(config.getBungeeMethod()).thenReturn("PLUGINMESSAGING"); + VotingPluginProxyConfig fresh = validHttpConfig(); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("HTTP"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + doThrow(new IOException("verification failed")).when(config).verifyControlProxyRoutingInstalled(); + + assertThrows(IOException.class, + () -> service.apply(new ProxyMethodConfiguration(BungeeMethod.HTTP), service.read().revision())); + + verify(proxy).prepareHttpTransportChange(fresh); + verify(proxy).cancelPreparedHttpTransportChange(); + } + + @Test + void rejectsPublicEndpointChangeOnAnOccupiedListenerBeforePublishing() throws Exception { + when(config.getBungeeMethod()).thenReturn("HTTP"); + VotingPluginProxyConfig fresh = validHttpConfig(); + when(fresh.getHttpPublicEndpoint()).thenReturn("https://new.example.test:1297"); + when(proxy.hasMatchingLiveHttpTransport(fresh)).thenReturn(false); + when(proxy.hasLiveHttpBind(fresh)).thenReturn(true); + doAnswer(invocation -> { + VotingPluginProxyConfig.ControlProxyMethodValidator validator = invocation.getArgument(2); + validator.validate(fresh); + return null; + }).when(config).persistControlProxyMethod(org.mockito.ArgumentMatchers.eq("HTTP"), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any()); + + assertThrows(IllegalArgumentException.class, + () -> service.apply(new ProxyMethodConfiguration(BungeeMethod.HTTP), service.read().revision())); + verify(proxy, never()).prepareHttpTransportChange(fresh); + verify(config, never()).verifyControlProxyRoutingInstalled(); + } + + private VotingPluginProxyConfig validHttpConfig() { + VotingPluginProxyConfig fresh = mock(VotingPluginProxyConfig.class); + when(fresh.getHttpHost()).thenReturn("127.0.0.1"); + when(fresh.getHttpPort()).thenReturn(1297); + when(fresh.getHttpPublicEndpoint()).thenReturn("https://proxy.example.test:1297"); + return fresh; + } } 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 d1fbadaf9..d3eb66581 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 @@ -3,16 +3,88 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; +import java.util.UUID; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import com.bencodez.simpleapi.servercomm.sockets.ClientHandler; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; +import com.bencodez.votingplugin.proxy.VotingPluginWire; class MultiProxyHandlerLifecycleTest { + @Test + void doesNotAcceptAnEnvelopeWhenNoSocketDestinationExists() { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + + assertFalse(handler.sendMultiProxyEnvelopeAccepted(JsonEnvelope.builder("vote").build())); + } + + @Test + void rejectsSocketSendWhenAnyConfiguredDestinationFails() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("backend")); + ClientHandler failing = mock(ClientHandler.class); + doThrow(new IllegalStateException("socket unavailable")).when(failing).sendEnvelope(org.mockito.ArgumentMatchers.any()); + java.lang.reflect.Field clients = MultiProxyHandler.class.getDeclaredField("multiproxyClientHandles"); + clients.setAccessible(true); + Map configured = new LinkedHashMap<>(); + configured.put("backend", failing); + clients.set(handler, configured); + + assertFalse(handler.sendMultiProxyEnvelopeAccepted(JsonEnvelope.builder("vote").build())); + } + + @Test + void acceptsAQueuedSocketSendWhenTheConfiguredDestinationReturnsNormally() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("backend")); + ClientHandler healthy = mock(ClientHandler.class); + java.lang.reflect.Field clients = MultiProxyHandler.class.getDeclaredField("multiproxyClientHandles"); + clients.setAccessible(true); + Map configured = new LinkedHashMap<>(); + configured.put("backend", healthy); + clients.set(handler, configured); + + assertTrue(handler.sendMultiProxyEnvelopeAccepted(JsonEnvelope.builder("vote").build())); + verify(healthy).sendEnvelope(org.mockito.ArgumentMatchers.any()); + } + + @Test + void redisSubsetSendPreservesConfiguredChannelCasing() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getProxyServers()).thenReturn(List.of("Proxy2")); + com.bencodez.simpleapi.servercomm.redis.RedisHandler redis = + mock(com.bencodez.simpleapi.servercomm.redis.RedisHandler.class); + java.lang.reflect.Field connection = MultiProxyHandler.class.getDeclaredField("multiProxyRedis"); + connection.setAccessible(true); + connection.set(handler, redis); + JsonEnvelope envelope = JsonEnvelope.builder("vote").build(); + + assertEquals(java.util.Set.of("Proxy2"), handler.getConfiguredMultiProxyVoteRecipients()); + assertTrue(handler.sendMultiProxyEnvelopeAccepted(envelope, List.of("proxy2"))); + + verify(redis).publishEnvelope("VotingPluginProxy_Proxy2", envelope); + } + @Test void stopsEveryReplacedSocketClientEvenWhenOneStopFails() { ClientHandler failing = mock(ClientHandler.class); @@ -27,4 +99,408 @@ void stopsEveryReplacedSocketClientEvenWhenOneStopFails() { verify(failing).stopConnection(); verify(healthy).stopConnection(); } + + @Test + void routesStableWireVoteWithoutOriginThroughLegacyTrigger() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + UUID voteId = UUID.randomUUID(); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", + com.bencodez.simpleapi.servercomm.codec.JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.vote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, false, true, "totals", voteId, + false, false, 1, 1)); + + 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.any(VoteTotalsSnapshot.class), + org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001")); + verify(handler, org.mockito.Mockito.never()).triggerVote(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyBoolean(), + org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void legacyVoteEnvelopeHasNoReliableOriginMetadata() { + UUID voteId = UUID.randomUUID(); + JsonEnvelope envelope = VotingPluginWire.vote("Player", "00000000-0000-0000-0000-000000000001", + "Service", 100L, false, true, "totals", voteId, false, false, 1, 1); + + assertEquals(voteId.toString(), envelope.getFields().get(VotingPluginWire.K_VOTE_ID)); + assertFalse(envelope.getFields().containsKey(VotingPluginWire.K_MULTI_PROXY_ORIGIN)); + } + + @Test + void forwardsOriginAndStableWireVoteIdToDurableTrigger() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + UUID voteId = UUID.randomUUID(); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, false, true, "totals", voteId, + false, false, 1, 1, "Primary")); + + 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.any(VoteTotalsSnapshot.class), + org.mockito.ArgumentMatchers.eq("00000000-0000-0000-0000-000000000001"), + org.mockito.ArgumentMatchers.eq(voteId), org.mockito.ArgumentMatchers.eq("Primary")); + } + + @Test + void rejectsReliableVoteEnvelopeWithoutStableId() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVote("Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, false, true, "totals", null, + false, false, 1, 1, "Primary")); + + verify(handler, org.mockito.Mockito.never()).triggerVote(org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyBoolean(), + org.mockito.ArgumentMatchers.anyBoolean(), org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.any(VoteTotalsSnapshot.class), org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void routesOnlyTheOriginatingProxyAcknowledgement() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Primary"); + UUID voteId = UUID.randomUUID(); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteAck(voteId, "Primary", "Replica")); + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteAck(voteId, "Other", "Replica")); + + verify(handler).onMultiProxyVoteAcknowledged(voteId, "Replica"); + } + + @Test + void routesOnlyAuthenticatedTargetedRetirementMessages() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Replica"); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.REDIS); + org.mockito.Mockito.when(handler.getProxyServers()).thenReturn(List.of("Primary")); + UUID voteId = UUID.randomUUID(); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteRetire(voteId, "Primary", "Replica")); + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteRetire(voteId, "Other", "Replica")); + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteRetire(voteId, "Primary", "Other")); + + verify(handler).onMultiProxyVoteRetirementRequested(voteId, "Primary"); + } + + @Test + void routesOnlyTheOriginatingProxyRetirementAcknowledgement() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, org.mockito.Mockito.CALLS_REAL_METHODS); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Primary"); + UUID voteId = UUID.randomUUID(); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteRetireAck(voteId, "Primary", "Replica")); + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyVoteRetireAck(voteId, "Other", "Replica")); + + verify(handler).onMultiProxyVoteRetirementAcknowledged(voteId, "Replica"); + } + + @Test + void fencesOnlyPeersThatAdvertiseDurableAcknowledgements() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("Legacy", "Capable")); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyCapabilities("Capable", 1)); + + assertEquals(java.util.Set.of("Capable"), handler.getMultiProxyVoteRecipients()); + } + + @Test + void durableAcknowledgementCapabilityExpiresAndCanBeRenewed() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("Capable")); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_LEASE_MILLIS - 1, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_LEASE_MILLIS, + 2_000L + MultiProxyHandler.VOTE_CAPABILITY_LEASE_MILLIS); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyCapabilities("Capable", 1, true)); + assertEquals(java.util.Set.of("Capable"), handler.getMultiProxyVoteRecipients()); + assertTrue(handler.getMultiProxyVoteRecipients().isEmpty()); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyCapabilities("Capable", 1, true)); + assertEquals(java.util.Set.of("Capable"), handler.getMultiProxyVoteRecipients()); + } + + @Test + void expiredKnownCapabilityWaitsForRenewalInsteadOfBecomingLegacy() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("Capable")); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_LEASE_MILLIS, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_LEASE_MILLIS); + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyCapabilities("Capable", 1, true)); + assertTrue(handler.getMultiProxyVoteRecipients().isEmpty()); + assertEquals(java.util.Set.of("Capable"), handler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()); + } + + @Test + void durableCapabilityIdentitySurvivesRestartAndFencesAnOfflinePeer(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler beforeRestart = capabilityHandler(dataDirectory, "Capable", "Legacy"); + handleCapability(beforeRestart, "Capable"); + assertEquals(java.util.Set.of("Capable"), beforeRestart.getMultiProxyVoteRecipients()); + + MultiProxyHandler afterRestart = capabilityHandler(dataDirectory, "Capable", "Legacy"); + afterRestart.restoreVoteCapabilityPeers(); + + assertTrue(afterRestart.getMultiProxyVoteRecipients().isEmpty()); + assertEquals(java.util.Set.of("Capable"), + afterRestart.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()); + assertFalse(afterRestart.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().contains("Legacy")); + } + + @Test + void malformedCapabilityStateBlocksForwardingUntilTheOperatorRepairsItAndRestarts(@TempDir Path dataDirectory) + throws Exception { + Files.writeString(dataDirectory.resolve(".multiproxy-capability-peers.json"), "not-json"); + MultiProxyHandler handler = capabilityHandler(dataDirectory, "Capable", "Legacy"); + handler.restoreVoteCapabilityPeers(); + + assertTrue(handler.isMultiProxyVoteCapabilityRecoveryBlocked()); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + handleCapability(handler, "Capable"); + assertTrue(handler.getMultiProxyVoteRecipients().isEmpty()); + assertTrue(handler.isMultiProxyVoteCapabilityRecoveryBlocked()); + + // Removing the invalid local state and restarting deterministically returns + // to fresh-install semantics: Legacy remains legacy and a new handshake + // classifies only Capable as an ACK peer. + Files.delete(dataDirectory.resolve(".multiproxy-capability-peers.json")); + MultiProxyHandler recovered = capabilityHandler(dataDirectory, "Capable", "Legacy"); + recovered.restoreVoteCapabilityPeers(); + assertFalse(recovered.isMultiProxyVoteCapabilityRecoveryBlocked()); + assertTrue(recovered.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + handleCapability(recovered, "Capable"); + assertEquals(java.util.Set.of("Capable"), recovered.getMultiProxyVoteRecipients()); + assertFalse(recovered.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().contains("Legacy")); + } + + @Test + void nonCanonicalCapabilityPeerBlocksForwardingInsteadOfBecomingLegacy(@TempDir Path dataDirectory) + throws Exception { + Files.writeString(dataDirectory.resolve(".multiproxy-capability-peers.json"), + "{\"version\":1,\"peers\":[\"Capable\"]}"); + + MultiProxyHandler handler = capabilityHandler(dataDirectory, "Capable", "Legacy"); + handler.restoreVoteCapabilityPeers(); + + assertTrue(handler.isMultiProxyVoteCapabilityRecoveryBlocked()); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + } + + @Test + void freshInstallKeepsLegacyPeersOnTheHistoricalRoute(@TempDir Path dataDirectory) { + MultiProxyHandler handler = capabilityHandler(dataDirectory, "Legacy"); + handler.restoreVoteCapabilityPeers(); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + } + + @Test + void newlyConfiguredPeerWaitsForCapabilityAnnouncementBeforeItIsClassified() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("Candidate")); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L); + + assertEquals(java.util.Set.of("Candidate"), + handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + handleCapability(handler, "Candidate"); + + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + assertEquals(java.util.Set.of("Candidate"), handler.getMultiProxyVoteRecipients()); + } + + @Test + void missedCapabilityAnnouncementFallsBackOnlyAfterItsBoundedPersistedWindow(@TempDir Path dataDirectory) { + MultiProxyHandler beforeRestart = capabilityHandler(dataDirectory, "Legacy"); + org.mockito.Mockito.when(beforeRestart.capabilityNowMillis()).thenReturn(1_000L); + assertEquals(java.util.Set.of("Legacy"), + beforeRestart.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + + MultiProxyHandler afterRestart = capabilityHandler(dataDirectory, "Legacy"); + afterRestart.restoreVoteCapabilityPeers(); + org.mockito.Mockito.when(afterRestart.capabilityNowMillis()).thenReturn( + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS); + assertTrue(afterRestart.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + // The expired deadline remains a legacy classification rather than beginning + // another discovery window on every retry/restart. + assertTrue(afterRestart.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + } + + @Test + void expiredDiscoveryPersistsItsFinalObservationOnlyOnce(@TempDir Path dataDirectory) { + MultiProxyHandler handler = capabilityHandler(dataDirectory, "Legacy"); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS + 1L); + + assertEquals(java.util.Set.of("Legacy"), + handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + assertEquals(1_000L + MultiProxyHandler.VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS, + assertDoesNotThrow(() -> MultiProxyCapabilityStore.load(dataDirectory)).lastObservedMillis()); + + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + assertEquals(1_000L + MultiProxyHandler.VOTE_CAPABILITY_DISCOVERY_WINDOW_MILLIS, + assertDoesNotThrow(() -> MultiProxyCapabilityStore.load(dataDirectory)).lastObservedMillis()); + } + + @Test + void discoveryRenewsBeforeItsBoundedFallbackInsteadOfAtExpiry() throws Exception { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of("Candidate")); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Primary"); + org.mockito.Mockito.when(handler.sendMultiProxyEnvelopeAccepted(org.mockito.ArgumentMatchers.any())).thenReturn(true); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L, 1_000L, + 11_000L, 11_000L, 21_000L, 21_000L, 30_000L, 31_000L); + org.mockito.Mockito.clearInvocations(handler); + + assertEquals(java.util.Set.of("Candidate"), handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + assertTrue(handler.renewMultiProxyVoteCapabilityDiscoveryIfDue()); + assertEquals(java.util.Set.of("Candidate"), handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + assertTrue(handler.renewMultiProxyVoteCapabilityDiscoveryIfDue()); + assertEquals(java.util.Set.of("Candidate"), handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + assertTrue(handler.renewMultiProxyVoteCapabilityDiscoveryIfDue()); + // This reply is asynchronous from the final early advertisement, but it is + // still accepted before the durable fallback boundary. + handleCapability(handler, "Candidate"); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + assertEquals(java.util.Set.of("Candidate"), handler.getMultiProxyVoteRecipients()); + org.mockito.Mockito.verify(handler, org.mockito.Mockito.times(3)) + .sendMultiProxyEnvelopeAccepted(org.mockito.ArgumentMatchers.any()); + } + + @Test + void clockRollbackExpiresDiscoveryInsteadOfExtendingItsPersistedWindow(@TempDir Path dataDirectory) { + MultiProxyHandler handler = capabilityHandler(dataDirectory, "Candidate"); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L, 500L); + + assertEquals(java.util.Set.of("Candidate"), handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery().isEmpty()); + + MultiProxyCapabilityStore.State persisted = assertDoesNotThrow(() -> MultiProxyCapabilityStore.load(dataDirectory)); + assertEquals(500L, persisted.discoveryDeadlines().get("candidate")); + assertEquals(500L, persisted.lastObservedMillis()); + } + + @Test + void removedCapabilityPeerIsPrunedDurablyBeforeSameNameIsReadded(@TempDir Path dataDirectory) throws Exception { + MultiProxyHandler original = capabilityHandler(dataDirectory, "Capable"); + handleCapability(original, "Capable"); + + MultiProxyHandler removed = capabilityHandler(dataDirectory, "Legacy"); + removed.restoreVoteCapabilityPeers(); + assertTrue(MultiProxyCapabilityStore.load(dataDirectory).peers().isEmpty()); + + MultiProxyHandler readded = capabilityHandler(dataDirectory, "Capable"); + readded.restoreVoteCapabilityPeers(); + assertTrue(readded.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + assertEquals(java.util.Set.of("Capable"), + readded.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()); + } + + @Test + void failedConfigurationPruningBlocksForwardingWithoutInstallingStalePeerState(@TempDir Path dataDirectory) + throws Exception { + MultiProxyCapabilityStore.save(dataDirectory, java.util.Set.of("capable"), Map.of(), 0L); + MultiProxyHandler removed = capabilityHandler(dataDirectory, "Legacy"); + try (org.mockito.MockedStatic store = org.mockito.Mockito.mockStatic( + MultiProxyCapabilityStore.class, org.mockito.Mockito.CALLS_REAL_METHODS)) { + store.when(() -> MultiProxyCapabilityStore.save(org.mockito.ArgumentMatchers.eq(dataDirectory), + org.mockito.ArgumentMatchers.anyCollection(), org.mockito.ArgumentMatchers.anyMap(), + org.mockito.ArgumentMatchers.anyLong())) + .thenThrow(new java.io.IOException("read-only capability state")); + removed.restoreVoteCapabilityPeers(); + } + + assertTrue(removed.isMultiProxyVoteCapabilityRecoveryBlocked()); + assertTrue(removed.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + assertEquals(java.util.Set.of("capable"), MultiProxyCapabilityStore.load(dataDirectory).peers()); + } + + @Test + void capabilityStoreRejectsSymlinkedState(@TempDir Path dataDirectory) throws Exception { + Path outside = Files.createTempFile("multiproxy-capability-outside", ".json"); + try { + Files.createSymbolicLink(dataDirectory.resolve(".multiproxy-capability-peers.json"), outside); + assertThrows(java.io.IOException.class, () -> MultiProxyCapabilityStore.load(dataDirectory)); + MultiProxyHandler handler = capabilityHandler(dataDirectory, "Capable"); + handler.restoreVoteCapabilityPeers(); + handleCapability(handler, "Capable"); + assertTrue(handler.getMultiProxyVoteRecipients().isEmpty()); + assertTrue(handler.isMultiProxyVoteCapabilityRecoveryBlocked()); + assertTrue(handler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal().isEmpty()); + } finally { + Files.deleteIfExists(outside); + } + } + + private static MultiProxyHandler capabilityHandler(Path dataDirectory, String... peers) { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyMethod()).thenReturn(MultiProxyMethod.SOCKETS); + org.mockito.Mockito.when(handler.getMultiProxyServers()).thenReturn(List.of(peers)); + org.mockito.Mockito.when(handler.getPluginDataFolder()).thenReturn(dataDirectory.toFile()); + return handler; + } + + private static void handleCapability(MultiProxyHandler handler, String peer) throws Exception { + Method handleEnvelope = MultiProxyHandler.class.getDeclaredMethod("handleEnvelope", JsonEnvelope.class); + handleEnvelope.setAccessible(true); + handleEnvelope.invoke(handler, VotingPluginWire.multiProxyCapabilities(peer, 1, true)); + } + + @Test + void renewalAdvertisementIsRateLimitedAfterAnInitialHandshake() { + MultiProxyHandler handler = mock(MultiProxyHandler.class, + org.mockito.Mockito.withSettings().useConstructor().defaultAnswer(org.mockito.Mockito.CALLS_REAL_METHODS)); + org.mockito.Mockito.when(handler.getMultiProxyServerName()).thenReturn("Primary"); + org.mockito.Mockito.when(handler.capabilityNowMillis()).thenReturn(1_000L, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_RENEWAL_MIN_INTERVAL_MILLIS - 1, + 1_000L + MultiProxyHandler.VOTE_CAPABILITY_RENEWAL_MIN_INTERVAL_MILLIS); + org.mockito.Mockito.when(handler.sendMultiProxyEnvelopeAccepted(org.mockito.ArgumentMatchers.any())).thenReturn(true); + org.mockito.Mockito.clearInvocations(handler); + + handler.announceMultiProxyVoteCapability(); + assertFalse(handler.renewMultiProxyVoteCapabilityIfDue()); + assertTrue(handler.renewMultiProxyVoteCapabilityIfDue()); + org.mockito.Mockito.verify(handler, org.mockito.Mockito.times(2)) + .sendMultiProxyEnvelopeAccepted(org.mockito.ArgumentMatchers.any()); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java index 9d77bd7df..df9c09bc2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/BungeeMethodTest.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.tests; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import org.junit.jupiter.api.Test; @@ -22,4 +23,12 @@ public void standaloneTransportsSupportBackendPresence() { } } } + + @Test + public void httpMethodNameIsCaseInsensitive() { + assertEquals(BungeeMethod.HTTP, BungeeMethod.getByName("http")); + assertEquals(BungeeMethod.HTTP, BungeeMethod.getByName("HTTP")); + assertFalse(BungeeMethod.HTTP.requiresPlayerOnline()); + assertTrue(BungeeMethod.HTTP.supportsBackendPresence()); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/JsonVoteCacheLegacyRemovalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/JsonVoteCacheLegacyRemovalTest.java new file mode 100644 index 000000000..a6e0d8f61 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/JsonVoteCacheLegacyRemovalTest.java @@ -0,0 +1,94 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.file.Path; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.bungee.BungeeJsonVoteCache; +import com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee; +import com.bencodez.votingplugin.proxy.velocity.VelocityJsonVoteCache; + +class JsonVoteCacheLegacyRemovalTest { + @TempDir + Path temporaryDirectory; + + @Test + void bungeeExactRemovalHonorsLegacyVoteId() { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + BungeeJsonVoteCache cache = new BungeeJsonVoteCache(plugin); + UUID removedId = UUID.randomUUID(); + UUID retainedId = UUID.randomUUID(); + putLegacyBungeeVote(cache, "0", removedId); + putLegacyBungeeVote(cache, "1", retainedId); + + cache.removeVote("server", vote(removedId)); + + assertTrue(cache.getConf().getAsJsonObject("VoteCache").getAsJsonObject("server").get("0").isJsonNull()); + assertTrue(cache.getConf().getAsJsonObject("VoteCache").getAsJsonObject("server").get("1").isJsonObject()); + } + + @Test + void velocityExactRemovalHonorsLegacyVoteId() { + VelocityJsonVoteCache cache = new VelocityJsonVoteCache( + temporaryDirectory.resolve("velocity-vote-cache.json").toFile()); + UUID removedId = UUID.randomUUID(); + UUID retainedId = UUID.randomUUID(); + putLegacyVelocityVote(cache, "0", removedId); + putLegacyVelocityVote(cache, "1", retainedId); + + cache.removeVote("server", vote(removedId)); + + assertFalse(cache.contains("VoteCache", "server", "0")); + assertTrue(cache.contains("VoteCache", "server", "1")); + } + + @Test + void bungeePendingRewardEnumerationSkipsMalformedServerKeys() { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + BungeeJsonVoteCache cache = new BungeeJsonVoteCache(plugin); + cache.setBoolean("VoteParty.PendingRewards.invalid*.delivery", true); + cache.setPendingVotePartyReward("Lobby", "delivery", true); + + assertEquals(java.util.List.of("lobby"), cache.getPendingVotePartyRewardServers()); + } + + @Test + void velocityPendingRewardEnumerationSkipsMalformedServerKeys() { + VelocityJsonVoteCache cache = new VelocityJsonVoteCache( + temporaryDirectory.resolve("velocity-pending-rewards.json").toFile()); + cache.set(new Object[] { "VoteParty", "PendingRewards", "invalid*", "delivery" }, true); + cache.setPendingVotePartyReward("Lobby", "delivery", true); + + assertEquals(java.util.List.of("lobby"), cache.getPendingVotePartyRewardServers()); + } + + private static OfflineBungeeVote vote(UUID voteId) { + return new OfflineBungeeVote(voteId, "Player", "player-uuid", "Service", 100L, true, "totals"); + } + + private static void putLegacyBungeeVote(BungeeJsonVoteCache cache, String index, UUID voteId) { + String root = "VoteCache.server." + index; + cache.setString(root + ".VoteID", voteId.toString()); + cache.setString(root + ".UUID", "player-uuid"); + cache.setString(root + ".Service", "Service"); + cache.setLong(root + ".Time", 100L); + } + + private static void putLegacyVelocityVote(VelocityJsonVoteCache cache, String index, UUID voteId) { + cache.set(new Object[] { "VoteCache", "server", index, "VoteID" }, voteId.toString()); + cache.set(new Object[] { "VoteCache", "server", index, "UUID" }, "player-uuid"); + cache.set(new Object[] { "VoteCache", "server", index, "Service" }, "Service"); + cache.set(new Object[] { "VoteCache", "server", index, "Time" }, 100L); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ProxyVoteEventNullServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ProxyVoteEventNullServiceTest.java new file mode 100644 index 000000000..2a5be828d --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ProxyVoteEventNullServiceTest.java @@ -0,0 +1,78 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.proxy.bungee.VoteEventBungee; +import com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee; +import com.bencodez.votingplugin.proxy.velocity.VoteEventVelocity; +import com.bencodez.votingplugin.proxy.velocity.VotingPluginVelocity; +import com.vexsoftware.votifier.model.Vote; + +class ProxyVoteEventNullServiceTest { + @Test + void bungeeNullServiceSchedulesVoteWithCompatibilityName() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + net.md_5.bungee.api.ProxyServer proxy = mock(net.md_5.bungee.api.ProxyServer.class); + net.md_5.bungee.api.scheduler.TaskScheduler scheduler = + mock(net.md_5.bungee.api.scheduler.TaskScheduler.class); + when(plugin.getProxy()).thenReturn(proxy); + when(proxy.getScheduler()).thenReturn(scheduler); + AtomicReference task = new AtomicReference<>(); + doAnswer(invocation -> { + task.set(invocation.getArgument(1)); + return null; + }).when(scheduler).runAsync(eq(plugin), any(Runnable.class)); + Vote vote = mock(Vote.class); + when(vote.getUsername()).thenReturn("Player"); + when(vote.getServiceName()).thenReturn(null); + com.vexsoftware.votifier.bungee.events.VotifierEvent event = + mock(com.vexsoftware.votifier.bungee.events.VotifierEvent.class); + when(event.getVote()).thenReturn(vote); + + new VoteEventBungee(plugin).onVote(event); + + assertNotNull(task.get()); + assertEquals("Empty", field(task.get(), "service")); + } + + @Test + void velocityNullServiceSchedulesVoteWithCompatibilityName() throws Exception { + VotingPluginVelocity plugin = mock(VotingPluginVelocity.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + AtomicReference task = new AtomicReference<>(); + doAnswer(invocation -> { + task.set(invocation.getArgument(0)); + return null; + }).when(timer).execute(any(Runnable.class)); + Vote vote = mock(Vote.class); + when(vote.getUsername()).thenReturn("Player"); + when(vote.getServiceName()).thenReturn(null); + com.vexsoftware.votifier.velocity.event.VotifierEvent event = + mock(com.vexsoftware.votifier.velocity.event.VotifierEvent.class); + when(event.getVote()).thenReturn(vote); + + new VoteEventVelocity(plugin).onVotifierEvent(event); + + assertNotNull(task.get()); + assertEquals("Empty", field(task.get(), "service")); + } + + private static Object field(Object target, String name) throws Exception { + Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } +} 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 d4d78e4d1..409bf0356 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VoteCacheHandlerVoteIdTest.java @@ -3,32 +3,49 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; import java.util.Collections; +import java.util.ArrayList; import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.atomic.AtomicReference; +import java.nio.file.Files; +import java.nio.file.Path; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.google.gson.JsonObject; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; +import com.bencodez.votingplugin.proxy.bungee.BungeeJsonVoteCache; +import com.bencodez.votingplugin.proxy.bungee.VotingPluginBungee; import com.bencodez.votingplugin.proxy.cache.DataNode; import com.bencodez.votingplugin.proxy.cache.IVoteCache; +import com.bencodez.votingplugin.proxy.cache.GsonDataNode; +import com.bencodez.votingplugin.proxy.cache.ProxyOnlineVoteCacheTable; +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.timequeue.VoteTimeQueue; @@ -36,6 +53,8 @@ * Regression tests for proxy vote identity across caches and delayed processing. */ public class VoteCacheHandlerVoteIdTest { + @TempDir + Path tempDir; private IVoteCache storage; private VoteCacheHandler handler; @@ -43,6 +62,7 @@ public class VoteCacheHandlerVoteIdTest { @BeforeEach public void setUp() { storage = mock(IVoteCache.class); + when(storage.getStoragePath()).thenReturn(tempDir.resolve("vote-cache.json")); when(storage.getServerVotes("server")).thenReturn(Collections.emptyList()); when(storage.getOnlineVotes("player-uuid")).thenReturn(Collections.emptyList()); handler = newHandler(storage); @@ -95,6 +115,66 @@ public void concurrentOnlineDeliveriesReserveVoteIdAtomically() throws Exception org.mockito.ArgumentMatchers.anyInt(), org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); } + @Test + public void multiProxyCompletionSurvivesHandlerRestart() { + UUID voteId = UUID.randomUUID(); + + assertTrue(handler.markMultiProxyVoteCompletedDurably(voteId)); + assertTrue(handler.hasMultiProxyVoteCompletion(voteId)); + + VoteCacheHandler restarted = newHandler(storage); + assertTrue(restarted.hasMultiProxyVoteCompletion(voteId)); + } + + @Test + public void multiProxyCompletionRetirementIsDurableAndIdempotent() { + UUID voteId = UUID.randomUUID(); + assertTrue(handler.markMultiProxyVoteCompletedDurably(voteId)); + + assertTrue(handler.removeMultiProxyVoteCompletion(voteId)); + assertFalse(handler.hasMultiProxyVoteCompletion(voteId)); + assertTrue(handler.removeMultiProxyVoteCompletion(voteId)); + } + + @Test + public void failedCompletionPublicationDoesNotEvictAnExistingFence() throws Exception { + Path completionDirectory = tempDir.resolve("vote-cache.json.completed-multiproxy-votes"); + Files.createDirectories(completionDirectory); + for (int index = 0; index < 4096; index++) { + Files.writeString(completionDirectory.resolve(UUID.randomUUID().toString()), "existing-" + index); + } + UUID blockedVoteId = UUID.randomUUID(); + // A directory at the target path makes the atomic publication fail after + // staging, exercising the failure window without changing file permissions. + Files.createDirectory(completionDirectory.resolve(blockedVoteId.toString())); + + assertFalse(handler.markMultiProxyVoteCompletedDurably(blockedVoteId)); + try (java.util.stream.Stream files = Files.list(completionDirectory)) { + assertEquals(4096L, files.filter(Files::isRegularFile).count()); + } + assertTrue(Files.isDirectory(completionDirectory.resolve(blockedVoteId.toString()))); + } + + @Test + public void completionFencesRemainWhileSenderOutboxesCanRetryIndefinitely() throws Exception { + Path completionDirectory = tempDir.resolve("vote-cache.json.completed-multiproxy-votes"); + Files.createDirectories(completionDirectory); + UUID oldestVoteId = UUID.randomUUID(); + Files.writeString(completionDirectory.resolve(oldestVoteId.toString()), oldestVoteId.toString()); + for (int index = 1; index < 4096; index++) { + UUID voteId = UUID.randomUUID(); + Files.writeString(completionDirectory.resolve(voteId.toString()), voteId.toString()); + } + UUID newestVoteId = UUID.randomUUID(); + + assertTrue(handler.markMultiProxyVoteCompletedDurably(newestVoteId)); + assertTrue(handler.hasMultiProxyVoteCompletion(oldestVoteId)); + assertTrue(handler.hasMultiProxyVoteCompletion(newestVoteId)); + try (java.util.stream.Stream files = Files.list(completionDirectory)) { + assertEquals(4097L, files.filter(Files::isRegularFile).count()); + } + } + @Test public void distinctVoteIdsAreNotCollapsed() { handler.addServerVote("server", vote(UUID.randomUUID(), 100L)); @@ -111,6 +191,210 @@ public void missingVoteIdsRemainLegacyCompatible() { assertEquals(2, handler.getVotes("server").size()); } + @Test + public void serverVoteIsNotExposedWhenJsonJournalCannotSave() { + doThrow(new RuntimeException("save failed")).when(storage).save(); + + assertFalse(handler.addServerVoteDurably("server", vote(UUID.randomUUID(), 100L))); + assertTrue(handler.getVotes("server").isEmpty()); + } + + @Test + public void onlineVoteIsNotExposedWhenJsonJournalCannotSave() { + doThrow(new RuntimeException("save failed")).when(storage).save(); + + assertFalse(handler.addOnlineVoteDurably("player-uuid", vote(UUID.randomUUID(), 100L))); + assertTrue(handler.getOnlineVotes("player-uuid").isEmpty()); + } + + @Test + public void failedServerInsertIsPublishedOnlyAfterPersistenceRetrySucceeds() { + OfflineBungeeVote pending = vote(UUID.randomUUID(), 100L); + doThrow(new RuntimeException("save failed")).doNothing().when(storage).save(); + + assertFalse(handler.addServerVoteDurably("server", pending)); + assertTrue(handler.retainServerVoteForPersistenceRetry("server", pending)); + assertTrue(handler.getVotes("server").isEmpty()); + + assertTrue(handler.retryPendingVotePersistence()); + assertEquals(List.of(pending), handler.getVotes("server")); + } + + @Test + public void failedOnlineInsertIsPublishedOnlyAfterPersistenceRetrySucceeds() { + OfflineBungeeVote pending = vote(UUID.randomUUID(), 100L); + doThrow(new RuntimeException("save failed")).doNothing().when(storage).save(); + + assertFalse(handler.addOnlineVoteDurably("player-uuid", pending)); + assertTrue(handler.retainOnlineVoteForPersistenceRetry("player-uuid", pending)); + assertTrue(handler.getOnlineVotes("player-uuid").isEmpty()); + + assertTrue(handler.retryPendingVotePersistence()); + assertEquals(List.of(pending), handler.getOnlineVotes("player-uuid")); + } + + @Test + public void failedPersistenceRetryAdmissionIsBoundedAcrossCacheTypes() { + OfflineBungeeVote first = vote(UUID.randomUUID(), 0L); + assertTrue(handler.retainServerVoteForPersistenceRetry("server", first)); + for (int i = 1; i < 1024; i++) { + assertTrue(handler.retainServerVoteForPersistenceRetry("server", vote(UUID.randomUUID(), i))); + } + + assertFalse(handler.retainOnlineVoteForPersistenceRetry("another-player", vote(UUID.randomUUID(), 1025L))); + assertTrue(handler.retainServerVoteForPersistenceRetry("server", first)); + } + + @Test + public void expiredOnlineVoteRemovalKeepsCollidingVoteId() { + UUID retainedId = UUID.randomUUID(); + OfflineBungeeVote expired = vote(UUID.randomUUID(), 100L); + OfflineBungeeVote retained = vote(retainedId, 100L); + handler.addOnlineVote("player-uuid", expired); + handler.addOnlineVote("player-uuid", retained); + + handler.removeOnlineVotes(new ArrayList<>(List.of(expired))); + + assertEquals(List.of(retained), handler.getOnlineVotes("player-uuid")); + verify(storage).removeOnlineVote(same(expired)); + } + + @Test + public void failedExpiredOnlineVoteRemovalKeepsInMemoryEntries() { + OfflineBungeeVote expired = vote(UUID.randomUUID(), 100L); + OfflineBungeeVote retained = vote(UUID.randomUUID(), 101L); + handler = newHandler(storage, false); + handler.addOnlineVote("player-uuid", expired); + handler.addOnlineVote("player-uuid", retained); + + handler.removeOnlineVotes(new ArrayList<>(List.of(expired))); + + assertEquals(List.of(expired, retained), handler.getOnlineVotes("player-uuid")); + } + + @Test + public void failedDurableServerSaveRollsBackJsonMutationBeforeRetry() throws Exception { + Path journal = Files.createTempFile("votingplugin-server-rollback", ".json"); + try { + ArrayList keys = new ArrayList<>(); + when(storage.getStoragePath()).thenReturn(journal); + when(storage.getServerVotes("server")).thenAnswer(invocation -> new ArrayList<>(keys)); + org.mockito.Mockito.doAnswer(invocation -> { + keys.add(String.valueOf(invocation.getArgument(1, Integer.class))); + return null; + }).when(storage).addVote(eq("server"), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + doThrow(new java.io.IOException("save failed")).when(storage).saveDurably(); + org.mockito.Mockito.doAnswer(invocation -> { keys.clear(); return null; }).when(storage).reload(); + handler = newVerifyingHandler(storage); + + assertFalse(handler.addServerVoteDurably("server", vote(UUID.randomUUID(), 100L))); + assertFalse(handler.addServerVoteDurably("server", vote(UUID.randomUUID(), 101L))); + + verify(storage, times(2)).addVote(eq("server"), eq(0), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + verify(storage, never()).addVote(eq("server"), eq(1), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + verify(storage, times(2)).reload(); + } finally { + Files.deleteIfExists(journal); + } + } + + @Test + public void failedDurableOnlineSaveRollsBackJsonMutationBeforeRetry() throws Exception { + Path journal = Files.createTempFile("votingplugin-online-rollback", ".json"); + try { + ArrayList keys = new ArrayList<>(); + when(storage.getStoragePath()).thenReturn(journal); + when(storage.getOnlineVotes("player-uuid")).thenAnswer(invocation -> new ArrayList<>(keys)); + org.mockito.Mockito.doAnswer(invocation -> { + keys.add(String.valueOf(invocation.getArgument(1, Integer.class))); + return null; + }).when(storage).addVoteOnline(eq("player-uuid"), org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + doThrow(new java.io.IOException("save failed")).when(storage).saveDurably(); + org.mockito.Mockito.doAnswer(invocation -> { keys.clear(); return null; }).when(storage).reload(); + handler = newVerifyingHandler(storage); + + assertFalse(handler.addOnlineVoteDurably("player-uuid", vote(UUID.randomUUID(), 100L))); + assertFalse(handler.addOnlineVoteDurably("player-uuid", vote(UUID.randomUUID(), 101L))); + + verify(storage, times(2)).addVoteOnline(eq("player-uuid"), eq(0), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + verify(storage, never()).addVoteOnline(eq("player-uuid"), eq(1), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + verify(storage, times(2)).reload(); + } finally { + Files.deleteIfExists(journal); + } + } + + @Test + public void failedRollbackReloadQuarantinesJsonJournal() throws Exception { + Path journal = Files.createTempFile("votingplugin-quarantined-journal", ".json"); + try { + when(storage.getStoragePath()).thenReturn(journal); + doThrow(new java.io.IOException("save failed")).when(storage).saveDurably(); + doThrow(new IllegalStateException("reload failed")).when(storage).reload(); + handler = newVerifyingHandler(storage); + + assertFalse(handler.addServerVoteDurably("server", vote(UUID.randomUUID(), 100L))); + assertFalse(handler.addServerVoteDurably("server", vote(UUID.randomUUID(), 101L))); + handler.removeVote("server", "player-uuid"); + handler.removeVotes("server"); + handler.removeOnlineVotes("player-uuid"); + handler.saveVoteCache(); + + verify(storage, times(1)).addVote(eq("server"), eq(0), + org.mockito.ArgumentMatchers.any(OfflineBungeeVote.class)); + verify(storage, times(1)).saveDurably(); + verify(storage, never()).removeServerVote("server", "player-uuid"); + verify(storage, never()).removeServerVotes("server"); + verify(storage, never()).removeOnlineVotes("player-uuid"); + verify(storage, never()).save(); + } finally { + Files.deleteIfExists(journal); + } + } + + @Test + public void serverVoteIsNotExposedWhenJsonSaveDoesNotReachDisk() throws Exception { + Path journal = Files.createTempFile("votingplugin-empty-journal", ".json"); + try { + when(storage.getStoragePath()).thenReturn(journal); + handler = newVerifyingHandler(storage); + + assertFalse(handler.addServerVoteDurably("server", vote(UUID.randomUUID(), 100L))); + assertTrue(handler.getVotes("server").isEmpty()); + verify(storage).reload(); + } finally { + Files.deleteIfExists(journal); + } + } + + @Test + public void serverVoteUpdateIsRejectedWhenJsonSaveDoesNotReachDisk() throws Exception { + Path journal = Files.createTempFile("votingplugin-empty-update-journal", ".json"); + try { + DataNode voteNode = mock(DataNode.class); + when(voteNode.isObject()).thenReturn(true); + stubString(voteNode, "UUID", "player-uuid"); + stubString(voteNode, "Service", "Service"); + stubLong(voteNode, "Time", 100L); + when(storage.getStoragePath()).thenReturn(journal); + when(storage.getServerVotes("server")).thenReturn(List.of("4")); + when(storage.getServerVotes("server", "4")).thenReturn(voteNode, (DataNode) null); + handler = newVerifyingHandler(storage); + OfflineBungeeVote updated = vote(null, 100L); + + assertFalse(handler.updateServerVote("server", updated)); + verify(storage).reload(); + } finally { + Files.deleteIfExists(journal); + } + } + @Test public void currentVoteIdKeyLoadsFromJsonCache() { UUID voteId = UUID.randomUUID(); @@ -121,6 +405,79 @@ public void currentVoteIdKeyLoadsFromJsonCache() { assertEquals(voteId, handler.getVotes("server").get(0).getVoteId()); } + @Test + public void legacyMySqlEmergencyRowsAreIgnoredWithoutJournalMarker() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Path cacheFile = tempDir.resolve("votecache.json"); + Files.writeString(cacheFile, """ + {"VoteCache":{"server":{"0":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}}}, + "OnlineCache":{"player-uuid":{"0":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}}}, + "TimedVoteCache":{"0":{"Name":"Player","Service":"Service","Time":100}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + VoteCacheHandler mysqlEnabled = newMysqlEnabledLoadHandler(durableStorage); + + mysqlEnabled.load(); + + assertTrue(mysqlEnabled.getVotes("server").isEmpty()); + assertTrue(mysqlEnabled.getOnlineVotes("player-uuid").isEmpty()); + assertTrue(mysqlEnabled.getTimeChangeQueue().isEmpty()); + } + + @Test + public void markedMySqlEmergencyRowsAreRecoveredFromJournal() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Path cacheFile = tempDir.resolve("votecache.json"); + Files.writeString(cacheFile, """ + {"VoteCache":{"server":{"0":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}}}, + "OnlineCache":{"player-uuid":{"0":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}}}, + "TimedVoteCache":{"0":{"Name":"Player","Service":"Service","Time":100}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + durableStorage.markEmergencyJournalUsed(); + VoteCacheHandler mysqlEnabled = newMysqlEnabledLoadHandler(durableStorage); + + mysqlEnabled.load(); + + assertEquals(1, mysqlEnabled.getVotes("server").size()); + assertEquals(1, mysqlEnabled.getOnlineVotes("player-uuid").size()); + assertEquals(1, mysqlEnabled.getTimeChangeQueue().size()); + } + + @Test + public void legacyRowsNeverImportAfterMySqlStartsEmergencyJournal() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Path cacheFile = tempDir.resolve("votecache.json"); + Files.writeString(cacheFile, """ + {"VoteCache":{"server":{"0":{"Name":"LegacyPlayer","Service":"Service","UUID":"legacy-uuid","Time":100}}}, + "OnlineCache":{"legacy-uuid":{"0":{"Name":"LegacyPlayer","Service":"Service","UUID":"legacy-uuid","Time":100}}}, + "TimedVoteCache":{"0":{"Name":"LegacyPlayer","Service":"Service","Time":100}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + VoteCacheHandler writer = newMysqlEnabledLoadHandler(durableStorage); + + assertTrue(writer.addServerVoteDurably("server-new", + new OfflineBungeeVote(UUID.randomUUID(), "NewPlayer", "new-uuid", "Service", 200L, true, ""))); + assertTrue(writer.addOnlineVoteDurably("new-uuid", + new OfflineBungeeVote(UUID.randomUUID(), "NewPlayer", "new-uuid", "Service", 200L, true, ""))); + VoteTimeQueue emergencyTimed = new VoteTimeQueue(UUID.randomUUID(), "NewPlayer", "Service", 300L); + emergencyTimed.setUuid("new-uuid"); + assertTrue(writer.addTimeVoteToCache(emergencyTimed)); + + VoteCacheHandler restarted = newMysqlEnabledLoadHandler(durableStorage); + restarted.load(); + + assertEquals(1, restarted.getVotes("server-new").size()); + assertTrue(restarted.getVotes("server").isEmpty()); + assertEquals(1, restarted.getOnlineVotes("new-uuid").size()); + assertTrue(restarted.getOnlineVotes("legacy-uuid").isEmpty()); + assertEquals(1, restarted.getTimeChangeQueue().size()); + assertEquals("new-uuid", restarted.getTimeChangeQueue().element().getUuid()); + } + @Test public void legacyVoteIdKeyLoadsFromJsonCache() { UUID voteId = UUID.randomUUID(); @@ -162,6 +519,221 @@ public void timedVoteIsPersistedImmediatelyWithItsId() { assertEquals(voteId, handler.getTimeChangeQueue().element().getVoteId()); } + @Test + public void legacyTimedVoteIdIsDurableBeforeReliableForwarding() { + VoteTimeQueue queued = new VoteTimeQueue("Player", "Service", 100L); + queued.setTimedVoteCacheJsonKey("2"); + JsonObject legacyJson = new JsonObject(); + legacyJson.addProperty("Name", "Player"); + legacyJson.addProperty("Service", "Service"); + legacyJson.addProperty("Time", 100L); + AtomicReference stored = new AtomicReference<>(new GsonDataNode(legacyJson)); + when(storage.getTimedVoteCache()).thenReturn(List.of("2")); + when(storage.getTimedVoteCache("2")).thenAnswer(ignored -> stored.get()); + org.mockito.Mockito.doAnswer(invocation -> { + stored.set(timedVoteNode(invocation.getArgument(1))); + return null; + }).when(storage).addTimedVote(eq(2), org.mockito.ArgumentMatchers.any(VoteTimeQueue.class)); + UUID voteId = UUID.randomUUID(); + + assertTrue(handler.assignLegacyTimeVoteId(queued, voteId)); + assertEquals(voteId, queued.getVoteId()); + assertEquals(voteId.toString(), stored.get().get("VoteId").asString()); + verify(storage).save(); + } + + @Test + public void identicalLegacyTimedJsonRowsKeepDistinctIdsAndDeleteIndividually() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Files.writeString(tempDir.resolve("votecache.json"), """ + {"TimedVoteCache":{"0":{"Name":"Player","Service":"Service","Time":100,"UUID":"player-uuid"}, + "1":{"Name":"Player","Service":"Service","Time":100,"UUID":"player-uuid"}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + + VoteCacheHandler durableHandler = newVerifyingHandler(durableStorage); + durableHandler.load(); + VoteTimeQueue first = durableHandler.getTimeChangeQueue().stream() + .filter(vote -> "0".equals(vote.getTimedVoteCacheJsonKey())).findFirst().orElseThrow(); + VoteTimeQueue second = durableHandler.getTimeChangeQueue().stream() + .filter(vote -> "1".equals(vote.getTimedVoteCacheJsonKey())).findFirst().orElseThrow(); + UUID firstId = first.legacyTimedVoteId(); + UUID secondId = second.legacyTimedVoteId(); + + assertNotEquals(firstId, secondId); + assertEquals(firstId, first.legacyTimedVoteId()); + assertTrue(durableHandler.assignLegacyTimeVoteId(first, firstId)); + assertTrue(durableHandler.assignLegacyTimeVoteId(second, secondId)); + assertTrue(durableHandler.removeTimeVote(first)); + + assertFalse(durableStorage.getTimedVoteCache().contains("0")); + assertTrue(durableStorage.getTimedVoteCache().contains("1")); + assertEquals(secondId.toString(), durableStorage.getTimedVoteCache("1").get("VoteId").asString()); + } + + @Test + public void legacySqlAndJsonTimedRowsRemainDistinctWithoutVoteIds() throws Exception { + DataNode jsonVote = mock(DataNode.class); + when(storage.hasEmergencyJournalMarker()).thenReturn(true); + when(storage.getTimedVoteCache()).thenReturn(List.of("2")); + when(storage.getTimedVoteCache("2")).thenReturn(jsonVote); + when(storage.getServers()).thenReturn(Collections.emptyList()); + when(storage.getPlayers()).thenReturn(Collections.emptyList()); + when(jsonVote.isObject()).thenReturn(true); + stubString(jsonVote, "Name", "Player"); + stubString(jsonVote, "Service", "Service"); + stubLong(jsonVote, "Time", 100L); + + VoteTimeQueue sqlVote = legacyTimedVote(); + sqlVote.setTimedVoteCacheRowId(41); + handler.getTimeChangeQueue().add(sqlVote); + var loadEmergencyVotes = VoteCacheHandler.class.getDeclaredMethod("loadJsonEmergencyVotes"); + loadEmergencyVotes.setAccessible(true); + + loadEmergencyVotes.invoke(handler); + + assertEquals(2, handler.getTimeChangeQueue().size()); + VoteTimeQueue jsonLoaded = handler.getTimeChangeQueue().stream() + .filter(vote -> "2".equals(vote.getTimedVoteCacheJsonKey())).findFirst().orElseThrow(); + assertNull(jsonLoaded.getVoteId()); + assertEquals(-1, jsonLoaded.getTimedVoteCacheRowId()); + } + + @Test + public void identicalLegacyServerJsonRowsKeepDistinctEntryIdentitiesAndDeleteIndividually() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Files.writeString(tempDir.resolve("votecache.json"), """ + {"VoteCache":{"server":{"0":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}, + "1":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + VoteCacheHandler durableHandler = newVerifyingHandler(durableStorage); + + durableHandler.load(); + OfflineBungeeVote first = durableHandler.getVotes("server").stream() + .filter(vote -> "0".equals(vote.getServerVoteCacheJsonKey())).findFirst().orElseThrow(); + OfflineBungeeVote second = durableHandler.getVotes("server").stream() + .filter(vote -> "1".equals(vote.getServerVoteCacheJsonKey())).findFirst().orElseThrow(); + + assertEquals(2, durableHandler.getVotes("server").size()); + durableHandler.removeServerVotes("server", new ArrayList<>(List.of(first))); + + assertFalse(durableStorage.getServerVotes("server").contains("0")); + assertTrue(durableStorage.getServerVotes("server").contains("1")); + assertEquals("1", second.getServerVoteCacheJsonKey()); + } + + @Test + public void expirationDoesNotReuseAJsonEntryKeyAcrossServers() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + long expired = System.currentTimeMillis() - java.util.concurrent.TimeUnit.DAYS.toMillis(2); + long current = System.currentTimeMillis(); + Files.writeString(tempDir.resolve("votecache.json"), """ + {"VoteCache":{"server-a":{"0":{"Name":"A","Service":"Service","UUID":"player-a","Time":%d}}, + "server-b":{"0":{"Name":"B","Service":"Service","UUID":"player-b","Time":%d}}}} + """.formatted(expired, current)); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + VoteCacheHandler durableHandler = newVerifyingHandler(durableStorage); + durableHandler.load(); + + durableHandler.checkVoteCacheTime(1); + + assertTrue(durableStorage.getServerVotes("server-a") == null + || durableStorage.getServerVotes("server-a").isEmpty()); + assertTrue(durableStorage.getServerVotes("server-b").contains("0")); + assertEquals(1, durableHandler.getVotes("server-b").size()); + } + + @Test + public void identicalLegacyOnlineJsonRowsKeepDistinctEntryIdentitiesAndDeleteIndividually() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Files.writeString(tempDir.resolve("votecache.json"), """ + {"OnlineCache":{"player-uuid":{"0":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}, + "1":{"Name":"Player","Service":"Service","UUID":"player-uuid","Time":100}}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + VoteCacheHandler durableHandler = newVerifyingHandler(durableStorage); + + durableHandler.load(); + OfflineBungeeVote first = durableHandler.getOnlineVotes("player-uuid").stream() + .filter(vote -> "0".equals(vote.getOnlineVoteCacheJsonKey())).findFirst().orElseThrow(); + OfflineBungeeVote second = durableHandler.getOnlineVotes("player-uuid").stream() + .filter(vote -> "1".equals(vote.getOnlineVoteCacheJsonKey())).findFirst().orElseThrow(); + + assertEquals(2, durableHandler.getOnlineVotes("player-uuid").size()); + assertTrue(durableHandler.tryRemoveOnlineVote("player-uuid", first)); + + assertFalse(durableStorage.getOnlineVotes("player-uuid").contains("0")); + assertTrue(durableStorage.getOnlineVotes("player-uuid").contains("1")); + assertEquals("1", second.getOnlineVoteCacheJsonKey()); + } + + @Test + public void onlineJsonEntryKeysAreScopedToTheirPlayer() throws Exception { + VotingPluginBungee plugin = mock(VotingPluginBungee.class); + when(plugin.getDataFolder()).thenReturn(tempDir.toFile()); + Files.writeString(tempDir.resolve("votecache.json"), """ + {"OnlineCache":{"player-a":{"0":{"Name":"A","Service":"Service","UUID":"player-a","Time":100}}, + "player-b":{"0":{"Name":"B","Service":"Service","UUID":"player-b","Time":100}}}} + """); + BungeeJsonVoteCache durableStorage = new BungeeJsonVoteCache(plugin); + VoteCacheHandler durableHandler = newVerifyingHandler(durableStorage); + durableHandler.load(); + OfflineBungeeVote playerB = durableHandler.getOnlineVotes("player-b").get(0); + + assertTrue(durableHandler.tryRemoveOnlineVote("player-b", playerB)); + + assertTrue(durableStorage.getOnlineVotes("player-a").contains("0")); + assertFalse(durableStorage.getOnlineVotes("player-b").contains("0")); + } + + @Test + public void unpairedLegacyServerSqlRowDoesNotDeleteJsonRows() throws Exception { + OfflineBungeeVote sqlVote = vote(null, 100L); + sqlVote.setServerVoteCacheRowId(41); + cachedVotes(handler, "cachedVotes").put("server", new ArrayList<>(List.of(sqlVote))); + ProxyVoteCacheTable sql = mock(ProxyVoteCacheTable.class); + when(sql.tryRemoveVote(same(sqlVote), eq("server"))).thenReturn(true); + setPrivateField(handler, "useMySQL", true); + setPrivateField(handler, "voteCacheTable", sql); + + handler.removeServerVotes("server", new ArrayList<>(List.of(sqlVote))); + + verify(storage, never()).removeVote(eq("server"), same(sqlVote)); + assertTrue(handler.getVotes("server").isEmpty()); + } + + @Test + public void unpairedLegacyOnlineSqlRowDoesNotDeleteJsonRows() throws Exception { + OfflineBungeeVote sqlVote = vote(null, 100L); + sqlVote.setOnlineVoteCacheRowId(42); + cachedVotes(handler, "cachedOnlineVotes").put("player-uuid", new ArrayList<>(List.of(sqlVote))); + ProxyOnlineVoteCacheTable sql = mock(ProxyOnlineVoteCacheTable.class); + when(sql.tryRemoveVote(same(sqlVote))).thenReturn(true); + setPrivateField(handler, "useMySQL", true); + setPrivateField(handler, "onlineVoteCacheTable", sql); + + assertTrue(handler.tryRemoveOnlineVote("player-uuid", sqlVote)); + + verify(storage, never()).removeOnlineVote(same(sqlVote)); + assertTrue(handler.getOnlineVotes("player-uuid").isEmpty()); + } + + @Test + public void identicalLegacyTimedSqlRowsUsePrimaryKeysForDistinctStableIds() { + VoteTimeQueue first = legacyTimedVote(); + VoteTimeQueue second = legacyTimedVote(); + first.setTimedVoteCacheRowId(41); + second.setTimedVoteCacheRowId(42); + + assertNotEquals(first.legacyTimedVoteId(), second.legacyTimedVoteId()); + assertEquals(first.legacyTimedVoteId(), first.legacyTimedVoteId()); + } + @Test public void timedVoteDeliveryStateIsUpdatedByVoteId() { UUID voteId = UUID.randomUUID(); @@ -180,26 +752,152 @@ public void timedVoteDeliveryStateIsUpdatedByVoteId() { } @Test - public void timedVoteIsRemovedFromMemoryOnlyAfterJsonSaveSucceeds() { + public void timedVoteSqlTwinRejectsJsonOnlyDeliveryUpdate() throws Exception { + UUID voteId = UUID.randomUUID(); + VoteTimeQueue queued = new VoteTimeQueue(voteId, "Player", "Service", 100L); + queued.setTimedVoteCacheRowId(42); + queued.setTimedVoteCacheJsonKey("2"); + DataNode stored = mock(DataNode.class); + when(stored.isObject()).thenReturn(true); + stubString(stored, "VoteId", voteId.toString()); + when(storage.getTimedVoteCache()).thenReturn(List.of("2")); + when(storage.getTimedVoteCache("2")).thenReturn(stored); + ProxyTimedVoteCacheTable sql = mock(ProxyTimedVoteCacheTable.class); + when(sql.updateTimedVote(queued)).thenReturn(false); + setPrivateField(handler, "useMySQL", true); + setPrivateField(handler, "timedVoteCacheTable", sql); + + assertFalse(handler.updateTimeVote(queued)); + + verify(sql).updateTimedVote(queued); + verify(storage, never()).addTimedVote(org.mockito.ArgumentMatchers.anyInt(), same(queued)); + } + + @Test + public void timedVoteSqlTwinRejectsJsonOnlyRemoval() throws Exception { + UUID voteId = UUID.randomUUID(); + VoteTimeQueue queued = new VoteTimeQueue(voteId, "Player", "Service", 100L); + queued.setTimedVoteCacheRowId(42); + queued.setTimedVoteCacheJsonKey("2"); + handler.getTimeChangeQueue().add(queued); + DataNode stored = mock(DataNode.class); + when(stored.isObject()).thenReturn(true); + stubString(stored, "VoteId", voteId.toString()); + when(storage.getTimedVoteCache()).thenReturn(List.of("2")); + when(storage.getTimedVoteCache("2")).thenReturn(stored); + ProxyTimedVoteCacheTable sql = mock(ProxyTimedVoteCacheTable.class); + when(sql.removeVote(queued)).thenReturn(false); + setPrivateField(handler, "useMySQL", true); + setPrivateField(handler, "timedVoteCacheTable", sql); + + assertFalse(handler.removeTimeVote(queued)); + + assertTrue(handler.getTimeChangeQueue().contains(queued)); + verify(sql).removeVote(queued); + verify(storage, never()).removeTimedVotes(); + } + + @Test + public void serverVoteSqlTwinRejectsJsonOnlyDeliveryUpdate() throws Exception { + OfflineBungeeVote vote = vote(UUID.randomUUID(), 100L); + vote.setServerVoteCacheRowId(41); + vote.setServerVoteCacheJsonKey("4"); + DataNode stored = mock(DataNode.class); + when(stored.isObject()).thenReturn(true); + when(storage.getServerVotes("server")).thenReturn(List.of("4")); + when(storage.getServerVotes("server", "4")).thenReturn(stored); + ProxyVoteCacheTable sql = mock(ProxyVoteCacheTable.class); + when(sql.updateProxyBroadcastState(vote, "server")).thenReturn(false); + setPrivateField(handler, "useMySQL", true); + setPrivateField(handler, "voteCacheTable", sql); + + assertFalse(handler.updateServerVote("server", vote)); + + verify(sql).updateProxyBroadcastState(vote, "server"); + verify(storage, never()).addVote(eq("server"), org.mockito.ArgumentMatchers.anyInt(), same(vote)); + } + + @Test + public void onlineVoteSqlTwinRejectsJsonOnlyDeliveryUpdate() throws Exception { + OfflineBungeeVote vote = vote(UUID.randomUUID(), 100L); + vote.setOnlineVoteCacheRowId(42); + vote.setOnlineVoteCacheJsonKey("3"); + DataNode stored = mock(DataNode.class); + when(stored.isObject()).thenReturn(true); + when(storage.getOnlineVotes("player-uuid")).thenReturn(List.of("3")); + when(storage.getOnlineVotes("player-uuid", "3")).thenReturn(stored); + ProxyOnlineVoteCacheTable sql = mock(ProxyOnlineVoteCacheTable.class); + when(sql.updateProxyBroadcastState(vote)).thenReturn(false); + setPrivateField(handler, "useMySQL", true); + setPrivateField(handler, "onlineVoteCacheTable", sql); + + assertFalse(handler.updateOnlineVote("player-uuid", vote)); + + verify(sql).updateProxyBroadcastState(vote); + verify(storage, never()).addVoteOnline(eq("player-uuid"), org.mockito.ArgumentMatchers.anyInt(), same(vote)); + } + + @Test + public void timedVoteAcknowledgementOutboxSurvivesHandlerRestart() { + UUID voteId = UUID.randomUUID(); + VoteTimeQueue queued = new VoteTimeQueue(voteId, "Player", "Service", 100L, true, + Set.of("backend-1"), Set.of("backend-1"), "totals", false, false, "player-uuid", + Map.of("backend-1", "00000000-0000-0000-0000-000000000181")); + queued.requireMultiProxyAcknowledgements("origin-proxy", Set.of("backend-1")); + queued.setMultiProxyLegacyPendingRecipients(Set.of("legacy-proxy")); + queued.acknowledgeMultiProxyRecipient("backend-1"); + queued.acknowledgeMultiProxyRetirement("backend-1"); + handler.addTimeVoteToCache(queued); + + when(storage.getTimedVoteCache()).thenReturn(List.of("0")); + when(storage.getTimedVoteCache("0")).thenReturn(timedVoteNode(queued)); + VoteCacheHandler restarted = newHandler(storage); + restarted.load(); + + VoteTimeQueue recovered = restarted.getTimeChangeQueue().element(); + assertEquals(voteId, recovered.getVoteId()); + assertTrue(recovered.isMultiProxyForwardingRequired()); + assertEquals(Set.of("backend-1"), recovered.getMultiProxyRecipients()); + assertEquals(Set.of("legacy-proxy"), recovered.getMultiProxyLegacyPendingRecipients()); + assertTrue(recovered.hasCompletedMultiProxyRetirements()); + assertEquals("00000000-0000-0000-0000-000000000181", + recovered.getHttpBroadcastDeliveryId("backend-1")); + } + + @Test + public void timedVoteIsRemovedFromMemoryOnlyAfterJsonSaveSucceeds() throws Exception { VoteTimeQueue queued = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L); assertTrue(handler.addTimeVoteToCache(queued)); assertTrue(handler.removeTimeVote(queued)); assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(storage).saveDurably(); } @Test - public void timedVoteRemainsQueuedWhenJsonDeleteCannotBeSaved() { + public void timedVoteRemainsQueuedWhenJsonDeleteCannotBeSaved() throws Exception { VoteTimeQueue queued = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L); assertTrue(handler.addTimeVoteToCache(queued)); - doThrow(new RuntimeException("save failed")).when(storage).save(); + doThrow(new java.io.IOException("save failed")).when(storage).saveDurably(); assertFalse(handler.removeTimeVote(queued)); assertTrue(handler.getTimeChangeQueue().contains(queued)); } + @Test + public void timedVoteCompletionTombstoneSurvivesIndependentCacheFailure() { + VoteTimeQueue queued = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L, false, + Set.of(), Set.of(), "totals", false, "player-uuid"); + + assertTrue(handler.markTimeVoteCompletedDurably(queued)); + assertTrue(handler.hasTimeVoteCompletion(queued)); + + handler.clearTimeVoteCompletion(queued); + assertFalse(handler.hasTimeVoteCompletion(queued)); + } + @Test public void legacyTimedVoteConstructorHasNoId() { VoteTimeQueue vote = new VoteTimeQueue("Player", "Service", 100L); @@ -222,6 +920,19 @@ public void timedVoteBroadcastStateRoundTripsThroughStorageEncoding() { VoteTimeQueue.decodeBroadcastForwardedServers(vote.encodeBroadcastForwardedServers())); } + @Test + public void timedVoteHttpBroadcastDeliveryIdsRoundTripThroughStorageEncoding() { + String deliveryId = "00000000-0000-0000-0000-000000000164"; + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L, true, + Set.of("Server1"), Set.of(), "totals", false, "uuid", + Map.of("Server1", deliveryId)); + + assertEquals(deliveryId, vote.getHttpBroadcastDeliveryId("server1")); + assertEquals(deliveryId, + VoteTimeQueue.decodeHttpBroadcastDeliveryIds(vote.encodeHttpBroadcastDeliveryIds()) + .get("server1")); + } + @Test public void cachedBroadcastStateSuppressesNonTargetsAndDeliveredTargets() { OfflineBungeeVote vote = new OfflineBungeeVote(UUID.randomUUID(), "Player", "player-uuid", "Service", 100L, @@ -305,20 +1016,44 @@ public void onlineBroadcastStateUpdateUsesVoteIdWhenTimestampsCollide() { @Test public void globalRewardClearRetainsOnlyIncompleteForwardBroadcasts() { + VoteCacheHandler guarded = spy(handler); OfflineBungeeVote pending = new OfflineBungeeVote(UUID.randomUUID(), "Player", "player-uuid", "Service", 100L, true, "totals", false, true, Set.of("Lobby", "Survival"), Set.of("Lobby"), false); OfflineBungeeVote complete = new OfflineBungeeVote(UUID.randomUUID(), "Player", "player-uuid", "Service", 101L, true, "totals", true, true, Set.of("Lobby"), Set.of("Lobby"), false); - handler.addOnlineVote("player-uuid", pending); - handler.addOnlineVote("player-uuid", complete); + guarded.addOnlineVote("player-uuid", pending); + guarded.addOnlineVote("player-uuid", complete); + doReturn(true).when(guarded).updateOnlineVote(eq("player-uuid"), org.mockito.ArgumentMatchers.any()); - handler.clearOnlineVoteRewards("player-uuid"); + guarded.clearOnlineVoteRewards("player-uuid"); - assertEquals(List.of(pending), handler.getOnlineVotes("player-uuid")); + assertEquals(List.of(pending), guarded.getOnlineVotes("player-uuid")); assertTrue(pending.isRewardDelivered()); assertTrue(pending.needsBroadcastOn("Survival")); } + @Test + public void failedGlobalRewardClearCannotLeaveRetainedVoteEligible() { + VoteCacheHandler guarded = spy(handler); + OfflineBungeeVote vote = vote(UUID.randomUUID(), 100L); + guarded.addOnlineVote("player-uuid", vote); + doReturn(false, true).when(guarded).updateOnlineVote("player-uuid", vote); + doReturn(false).when(guarded).tryRemoveOnlineVote("player-uuid", vote); + + guarded.clearOnlineVoteRewards("player-uuid"); + + assertTrue(vote.isRewardDelivered()); + assertTrue(vote.isDeliveryStateDirty()); + assertEquals(List.of(vote), guarded.getOnlineVotes("player-uuid")); + verify(guarded, never()).tryRemoveOnlineVote("player-uuid", vote); + + guarded.clearOnlineVoteRewards("player-uuid"); + + assertFalse(vote.isDeliveryStateDirty()); + assertEquals(List.of(vote), guarded.getOnlineVotes("player-uuid")); + verify(guarded).tryRemoveOnlineVote("player-uuid", vote); + } + @Test public void completedOnlineVoteRemovalKeepsCollidingVoteId() { UUID removedId = UUID.randomUUID(); @@ -332,6 +1067,18 @@ public void completedOnlineVoteRemovalKeepsCollidingVoteId() { assertEquals(List.of(retained), handler.getOnlineVotes("player-uuid")); } + @Test + public void completedServerVoteRemovalKeepsCollidingVoteId() { + OfflineBungeeVote removed = vote(UUID.randomUUID(), 100L); + OfflineBungeeVote retained = vote(UUID.randomUUID(), 100L); + handler.addServerVote("server", removed); + handler.addServerVote("server", retained); + + handler.removeServerVotes("server", new ArrayList<>(List.of(removed))); + + assertEquals(List.of(retained), handler.getVotes("server")); + } + @Test public void timedVoteBroadcastStateLoadsFromJsonCache() { IVoteCache stored = mock(IVoteCache.class); @@ -370,6 +1117,46 @@ public void timedVoteBroadcastStateLoadsFromJsonCache() { assertTrue(loaded.isProcessed()); } + @Test + public void timedVoteHttpBroadcastDeliveryIdLoadsFromJsonCache() { + IVoteCache stored = mock(IVoteCache.class); + DataNode timedNode = mock(DataNode.class); + when(stored.getTimedVoteCache()).thenReturn(List.of("0")); + when(stored.getTimedVoteCache("0")).thenReturn(timedNode); + when(stored.getServers()).thenReturn(Collections.emptyList()); + when(stored.getPlayers()).thenReturn(Collections.emptyList()); + when(timedNode.isObject()).thenReturn(true); + String deliveryId = "00000000-0000-0000-0000-000000000165"; + UUID voteId = UUID.randomUUID(); + stubString(timedNode, "Name", "Player"); + stubString(timedNode, "Service", "Service"); + stubLong(timedNode, "Time", 100L); + stubString(timedNode, "VoteId", voteId.toString()); + stubString(timedNode, "UUID", "uuid"); + stubBoolean(timedNode, "ProxyBroadcastHandled", true); + stubBoolean(timedNode, "MultiProxyForwardingHandled", true); + stubString(timedNode, "BroadcastTargets", VoteTimeQueue.encodeBroadcastServers(Set.of("Server1"))); + stubString(timedNode, "BroadcastForwardedServers", ""); + stubString(timedNode, "HttpBroadcastDeliveryIds", new VoteTimeQueue(voteId, "Player", "Service", 100L, + true, Set.of("Server1"), Set.of(), "", false, "uuid", Map.of("Server1", deliveryId)) + .encodeHttpBroadcastDeliveryIds()); + + handler = newHandler(stored); + handler.load(); + + assertEquals(deliveryId, handler.getTimeChangeQueue().element().getHttpBroadcastDeliveryId("SERVER1")); + assertTrue(handler.getTimeChangeQueue().element().isMultiProxyForwardingHandled()); + } + + @Test + public void legacyTimedVoteDefaultsMultiProxyForwardingFenceToFalse() { + VoteTimeQueue vote = new VoteTimeQueue(UUID.randomUUID(), "Player", "Service", 100L); + + assertFalse(vote.isMultiProxyForwardingHandled()); + vote.setMultiProxyForwardingHandled(true); + assertTrue(vote.isMultiProxyForwardingHandled()); + } + @Test public void onlineVoteBroadcastTargetsAndRewardStateLoadFromJsonCache() { IVoteCache stored = mock(IVoteCache.class); @@ -504,8 +1291,74 @@ private static OfflineBungeeVote vote(UUID voteId, long time) { return new OfflineBungeeVote(voteId, "Player", "player-uuid", "Service", time, true, "totals"); } + private static VoteTimeQueue legacyTimedVote() { + VoteTimeQueue vote = new VoteTimeQueue("Player", "Service", 100L); + vote.setUuid("player-uuid"); + return vote; + } + + private static DataNode timedVoteNode(VoteTimeQueue vote) { + JsonObject data = new JsonObject(); + data.addProperty("Name", vote.getName()); + data.addProperty("Service", vote.getService()); + data.addProperty("Time", vote.getTime()); + data.addProperty("VoteId", vote.getVoteId().toString()); + data.addProperty("UUID", vote.getUuid()); + data.addProperty("ProxyBroadcastHandled", vote.isProxyBroadcastHandled()); + data.addProperty("Totals", vote.getTotals()); + data.addProperty("Processed", vote.isProcessed()); + data.addProperty("MultiProxyForwardingHandled", vote.isMultiProxyForwardingHandled()); + data.addProperty("MultiProxyForwardingRequired", vote.isMultiProxyForwardingRequired()); + data.addProperty("RealVote", vote.isRealVote()); + data.addProperty("MultiProxyOrigin", vote.getMultiProxyOrigin()); + data.addProperty("MultiProxyCompletionPending", vote.isMultiProxyCompletionPending()); + data.addProperty("MultiProxyRecipients", vote.encodeMultiProxyRecipients()); + data.addProperty("MultiProxyAcknowledgedServers", vote.encodeMultiProxyAcknowledgedServers()); + data.addProperty("MultiProxyLegacyPendingRecipients", vote.encodeMultiProxyLegacyPendingRecipients()); + data.addProperty("BroadcastTargets", vote.encodeBroadcastTargets()); + data.addProperty("BroadcastForwardedServers", vote.encodeBroadcastForwardedServers()); + data.addProperty("HttpBroadcastDeliveryIds", vote.encodeHttpBroadcastDeliveryIds()); + return new GsonDataNode(data); + } + private static VoteCacheHandler newHandler(IVoteCache storage) { + return newHandler(storage, true); + } + + private static VoteCacheHandler newHandler(IVoteCache storage, boolean removalSucceeds) { return new VoteCacheHandler(null, false, false, null, false, storage) { + @Override + protected boolean verifyJsonServerVote(String server, int index, OfflineBungeeVote vote) { + storage.save(); + return true; + } + + @Override + protected boolean verifyJsonOnlineVote(String uuid, int index, OfflineBungeeVote vote) { + storage.save(); + return true; + } + + @Override + protected boolean verifyJsonTimeVote(int index, VoteTimeQueue vote) { + storage.save(); + return true; + } + + @Override + protected boolean removeJsonOnlineVoteDurably(String uuid, OfflineBungeeVote vote) { + storage.removeOnlineVote(vote); + storage.save(); + return removalSucceeds; + } + + @Override + protected boolean removeJsonServerVoteDurably(String server, OfflineBungeeVote vote) { + storage.removeVote(server, vote); + storage.save(); + return true; + } + @Override public void logInfo1(String msg) { } @@ -527,4 +1380,44 @@ public void debug1(String msg) { } }; } + + private static VoteCacheHandler newMysqlEnabledLoadHandler(IVoteCache storage) throws Exception { + ProxyVoteCacheTable voteTable = mock(ProxyVoteCacheTable.class); + ProxyOnlineVoteCacheTable onlineTable = mock(ProxyOnlineVoteCacheTable.class); + ProxyTimedVoteCacheTable timedTable = mock(ProxyTimedVoteCacheTable.class); + when(voteTable.getAllVotes()).thenReturn(Collections.emptyList()); + when(onlineTable.getAllVotes()).thenReturn(Collections.emptyList()); + when(timedTable.getAllVotes()).thenReturn(Collections.emptyList()); + + VoteCacheHandler mysqlEnabled = newHandler(storage); + setPrivateField(mysqlEnabled, "useMySQL", true); + setPrivateField(mysqlEnabled, "voteCacheTable", voteTable); + setPrivateField(mysqlEnabled, "onlineVoteCacheTable", onlineTable); + setPrivateField(mysqlEnabled, "timedVoteCacheTable", timedTable); + return mysqlEnabled; + } + + @SuppressWarnings("unchecked") + private static Map> cachedVotes(VoteCacheHandler target, String fieldName) + throws Exception { + var field = VoteCacheHandler.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (Map>) field.get(target); + } + + private static void setPrivateField(VoteCacheHandler target, String fieldName, Object value) throws Exception { + var field = VoteCacheHandler.class.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + + private static VoteCacheHandler newVerifyingHandler(IVoteCache storage) { + return new VoteCacheHandler(null, false, false, null, false, storage) { + @Override public void logInfo1(String msg) { } + @Override public void logSevere1(String msg) { } + @Override public void debug1(Exception e) { } + @Override public void debug1(Throwable e) { } + @Override public void debug1(String msg) { } + }; + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyCommandTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyCommandTest.java new file mode 100644 index 000000000..9c6d79782 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyCommandTest.java @@ -0,0 +1,39 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.votingplugin.proxy.VotingPluginProxy; +import com.bencodez.votingplugin.proxy.VotingPluginProxyCommand; + +public class VotingPluginProxyCommandTest { + @Test + void manualVoteReportsRetryableDurabilityFailure() { + VotingPluginProxy proxy = mock(VotingPluginProxy.class); + doThrow(VotingPluginProxy.VoteRetryException.class).when(proxy) + .vote(eq("Player"), eq("Service"), eq(false), eq(true), eq(0L), eq(null), eq(null), any(UUID.class)); + VotingPluginProxyCommand command = new VotingPluginProxyCommand(proxy); + + String firstResult = command.execute(new String[] { "vote", "Player", "Service" }); + String retryIdText = firstResult.substring(firstResult.lastIndexOf(' ') + 1); + UUID retryId = UUID.fromString(retryIdText); + String secondResult = command.execute(new String[] { "vote", "Player", "Service", retryIdText }); + + assertEquals("&cVote could not be stored safely. Retry with: vote Player Service " + retryId, firstResult); + assertEquals(firstResult, secondResult); + ArgumentCaptor ids = ArgumentCaptor.forClass(UUID.class); + verify(proxy, times(2)).vote(eq("Player"), eq("Service"), eq(false), eq(true), eq(0L), eq(null), eq(null), + ids.capture()); + assertEquals(java.util.List.of(retryId, retryId), ids.getAllValues()); + } +} 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 b6c587a5a..3b77956d2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.never; @@ -10,23 +11,29 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; import com.bencodez.advancedcore.bungeeapi.globaldata.GlobalDataHandlerProxy; +import com.bencodez.simpleapi.servercomm.codec.JsonEnvelope; +import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; import com.bencodez.simpleapi.servercomm.mysql.MySqlMessenger; import com.bencodez.votingplugin.proxy.BungeeMethod; import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.proxy.ProxyMysqlUserTable; import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.proxy.VotingPluginWire; +import com.bencodez.votingplugin.proxy.broadcast.ProxyBroadcastDecider; import com.bencodez.votingplugin.proxy.cache.VoteCacheHandler; import com.bencodez.votingplugin.proxy.multiproxy.MultiProxyHandler; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; public class VotingPluginProxyTest { + @TempDir + java.nio.file.Path temporaryDirectory; @InjectMocks private VotingPluginProxyTestImpl votingPluginProxy; @@ -46,37 +53,2190 @@ void setUp() { votingPluginProxy.setProxyMySQL(proxyMySQL); votingPluginProxy.setGlobalDataHandler(globalDataHandler); votingPluginProxy.setMultiProxyHandler(multiProxyHandler); + votingPluginProxy.setDataFolder(temporaryDirectory.toFile()); + Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any())).thenReturn(true); + Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any())).thenReturn(true); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Replica")); } + @Test + void httpMethodPreparationRejectsAnOccupiedPortBeforeConfigurationPublication() throws Exception { + try (java.net.ServerSocket occupied = new java.net.ServerSocket(0, 1, + java.net.InetAddress.getByName("127.0.0.1"))) { + com.bencodez.votingplugin.proxy.VotingPluginProxyConfig candidate = + Mockito.mock(com.bencodez.votingplugin.proxy.VotingPluginProxyConfig.class); + Mockito.when(candidate.getHttpHost()).thenReturn("127.0.0.1"); + Mockito.when(candidate.getHttpPort()).thenReturn(occupied.getLocalPort()); + Mockito.when(candidate.getHttpPublicEndpoint()) + .thenReturn("https://proxy.example.test:" + occupied.getLocalPort()); + + assertThrows(IllegalStateException.class, + () -> votingPluginProxy.prepareHttpTransportChange(candidate)); + } + } + + @Test + void indeterminateHttpVoteRetriesWithThePublishedStableDeliveryId() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + String deliveryId = "00000000-0000-0000-0000-000000000159"; + votingPluginProxy.failNextGeneratedHttpSend(deliveryId); + + assertTrue(votingPluginProxy.sendHttpVoteEnvelopeWithRecoveryForTest("Server1", + JsonEnvelope.builder("vote").build())); + assertEquals(java.util.List.of(deliveryId), votingPluginProxy.getAttemptedVotePartyDeliveryIds()); + } + + @Test + void indeterminateGenericHttpSendRecoversWithoutEscapingToRetryTheVote() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + String deliveryId = "00000000-0000-0000-0000-00000000016a"; + votingPluginProxy.failNextGeneratedHttpSend(deliveryId); + + assertTrue(votingPluginProxy.sendGenericHttpEnvelopeForTest("Server1", + VotingPluginWire.voteUpdate("uuid", 1, 10, "Service", 100L, "totals"))); + assertEquals(java.util.List.of(deliveryId), votingPluginProxy.getAttemptedVotePartyDeliveryIds()); + } + + @Test + void rejectedGenericHttpSendIsHandledWithoutEscapingToRetryTheVote() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + assertFalse(votingPluginProxy.sendGenericHttpEnvelopeForTest("Server1", + VotingPluginWire.voteUpdate("uuid", 1, 10, "Service", 100L, "totals"))); + } + + @Test + void rejectedRewardBearingAuxiliarySendRetainsItsStableDeliveryIdForRetry() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + String deliveryId = "00000000-0000-0000-0000-00000000016b"; + JsonEnvelope envelope = VotingPluginWire.voteDelayRejected("Player", + "00000000-0000-0000-0000-000000000001", "Service", true); + + assertFalse(votingPluginProxy.sendStableHttpEnvelopeForTest("Server1", deliveryId, envelope)); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + assertTrue(votingPluginProxy.sendStableHttpEnvelopeForTest("Server1", deliveryId, envelope)); + assertEquals(java.util.List.of(deliveryId, deliveryId), + votingPluginProxy.getAttemptedVotePartyDeliveryIds()); + } + + @Test + void rejectedOnlineHttpBroadcastRetriesWithTheSameDeliveryId() throws Exception { + Mockito.when(votingPluginProxy.getConfig().getProxyBroadcastEnabled()).thenReturn(true); + votingPluginProxy.setStandaloneBroadcastForwarding(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + votingPluginProxy.setStableHttpDeliveryResults(true, false, true); + votingPluginProxy.setGlobalMessageProxyHandlerForTest( + Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); + java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider"); + decider.setAccessible(true); + decider.set(votingPluginProxy, new ProxyBroadcastDecider(votingPluginProxy::getConfig, + votingPluginProxy::getAllAvailableServers, server -> true, server -> false)); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> votingPluginProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + votingPluginProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + assertEquals(3, votingPluginProxy.getAttemptedVotePartyDeliveryIds().size()); + assertFalse(votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(0) + .equals(votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(1))); + assertEquals(votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(1), + votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(2)); + } + + @Test + void cachedHttpVoteReusesItsDeterministicIdAfterRestart() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + OfflineBungeeVote pending = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "uuid", + "Service", 100L, false, "totals"); + + assertTrue(votingPluginProxy.sendHttpVoteEnvelopeWithRecoveryForTest("Server1", + JsonEnvelope.builder("vote").build(), pending)); + + OfflineBungeeVote restored = new OfflineBungeeVote(pending.getVoteId(), pending.getPlayerName(), + pending.getUuid(), pending.getService(), pending.getTime(), pending.isRealVote(), pending.getText(), + pending.isBroadcastForwarded(), pending.isProxyBroadcastHandled(), pending.getBroadcastTargets(), + pending.getBroadcastForwardedServers(), pending.isRewardDelivered(), + OfflineBungeeVote.decodeHttpDeliveryIds(pending.encodeHttpDeliveryIds())); + assertTrue(votingPluginProxy.sendHttpVoteEnvelopeWithRecoveryForTest("Server1", + JsonEnvelope.builder("vote").build(), restored)); + assertEquals(2, votingPluginProxy.getAttemptedVotePartyDeliveryIds().size()); + assertEquals(votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(0), + votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(1)); + } + + @Test + void standaloneHttpBroadcastUsesSeparateStableIdFromRewardDelivery() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + String rewardId = "00000000-0000-0000-0000-000000000162"; + OfflineBungeeVote pending = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "uuid", + "Service", 100L, true, "totals"); + pending.setHttpDeliveryId("Server1", rewardId); + + assertTrue(votingPluginProxy.sendHttpBroadcastEnvelopeWithRecoveryForTest("Server1", + JsonEnvelope.builder("vote").build(), pending)); + assertEquals(rewardId, pending.getHttpDeliveryId("Server1")); + assertEquals(1, votingPluginProxy.getAttemptedVotePartyDeliveryIds().size()); + assertFalse(rewardId.equals(votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(0))); + } + + @Test + void cachedStandaloneBroadcastReusesItsDeterministicIdAfterRestart() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + OfflineBungeeVote pending = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "uuid", + "Service", 100L, true, "totals"); + + assertTrue(votingPluginProxy.sendHttpBroadcastEnvelopeWithRecoveryForTest("Server1", + JsonEnvelope.builder("broadcast").build(), pending)); + OfflineBungeeVote restored = new OfflineBungeeVote(pending.getVoteId(), pending.getPlayerName(), pending.getUuid(), + pending.getService(), pending.getTime(), pending.isRealVote(), pending.getText(), false, true, + java.util.Set.of("Server1"), java.util.Set.of(), pending.isRewardDelivered(), + OfflineBungeeVote.decodeHttpDeliveryIds(pending.encodeHttpDeliveryIds()), + OfflineBungeeVote.decodeHttpBroadcastDeliveryIds(pending.encodeHttpBroadcastDeliveryIds())); + assertTrue(votingPluginProxy.sendHttpBroadcastEnvelopeWithRecoveryForTest("Server1", + JsonEnvelope.builder("broadcast").build(), restored)); + assertEquals(2, votingPluginProxy.getAttemptedVotePartyDeliveryIds().size()); + assertEquals(votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(0), + votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(1)); + } + + @Test + void acceptedRewardStillPersistsUnresolvedStandaloneBroadcast() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote state = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), true, java.util.Collections.emptyMap(), + java.util.Map.of("Server1", "00000000-0000-0000-0000-000000000169")); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.when(voteCache.addOnlineVoteDurably("player-uuid", state)).thenReturn(true); + + assertTrue(spyProxy.persistUncachedStandaloneBroadcastForTest("player-uuid", state, false)); + + assertTrue(state.isRewardDelivered()); + assertFalse(state.isBroadcastForwarded()); + verify(voteCache).addOnlineVoteDurably("player-uuid", state); + } + + @Test + void uncachedStandaloneBroadcastPersistsAsBroadcastOnlyState() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote state = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), false, java.util.Collections.emptyMap(), + java.util.Map.of("Server1", "00000000-0000-0000-0000-000000000169")); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.when(voteCache.addOnlineVoteDurably("player-uuid", state)).thenReturn(true); + + assertTrue(spyProxy.persistUncachedStandaloneBroadcastForTest("player-uuid", state, false)); + + assertTrue(state.isRewardDelivered()); + assertFalse(state.isBroadcastForwarded()); + verify(voteCache).addOnlineVoteDurably("player-uuid", state); + } + + @Test + void uncachedStandaloneBroadcastRetainsFailedDurabilityForRetry() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote state = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), false, java.util.Collections.emptyMap(), + java.util.Map.of("Server1", "00000000-0000-0000-0000-000000000169")); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.when(voteCache.addOnlineVoteDurably("player-uuid", state)).thenReturn(false); + Mockito.when(voteCache.retainOnlineVoteForPersistenceRetry("player-uuid", state)).thenReturn(true); + + assertFalse(spyProxy.persistUncachedStandaloneBroadcastForTest("player-uuid", state, false)); + verify(voteCache).addOnlineVoteDurably("player-uuid", state); + verify(voteCache).retainOnlineVoteForPersistenceRetry("player-uuid", state); + } + + @Test + void standaloneBroadcastDoesNotSendBeforeItsCanonicalStateIsDurable() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote state = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), true); + Mockito.when(voteCache.addOnlineVoteDurably("player-uuid", state)).thenReturn(false); + Mockito.when(voteCache.retainOnlineVoteForPersistenceRetry("player-uuid", state)).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + assertFalse(spyProxy.persistAndSendStandaloneBroadcastForTest("player-uuid", state, + java.util.Set.of("Server1"), new java.util.LinkedHashSet<>())); + + assertTrue(spyProxy.getAttemptedVotePartyDeliveryIds().isEmpty()); + verify(voteCache).retainOnlineVoteForPersistenceRetry("player-uuid", state); + verify(voteCache, never()).updateOnlineVote("player-uuid", state); + } + + @Test + void standaloneBroadcastPersistsCanonicalStateBeforeSending() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote state = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), true); + Mockito.when(voteCache.addOnlineVoteDurably("player-uuid", state)).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote("player-uuid", state)).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + assertTrue(spyProxy.persistAndSendStandaloneBroadcastForTest("player-uuid", state, + java.util.Set.of("Server1"), new java.util.LinkedHashSet<>())); + + assertEquals(1, spyProxy.getAttemptedVotePartyDeliveryIds().size()); + org.mockito.InOrder order = Mockito.inOrder(voteCache); + order.verify(voteCache).addOnlineVoteDurably("player-uuid", state); + order.verify(voteCache).updateOnlineVote("player-uuid", state); + verify(voteCache, never()).removeOnlineVote("player-uuid", state); + } + + @Test + void liveVotePropagatesRetryWhenProcessingCannotBeMadeDurable() { + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + votingPluginProxy.setProxyMySQL(null); + + com.bencodez.votingplugin.proxy.VotingPluginProxy.VoteRetryException failure = assertThrows( + com.bencodez.votingplugin.proxy.VotingPluginProxy.VoteRetryException.class, + () -> votingPluginProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001")); + + assertTrue(failure.getMessage().contains("retry is required")); + } + + @Test + void liveVoteRetryKeepsTimestampAndDoesNotApplyVotePartyTwice() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())) + .thenReturn(false, true, true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 0L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + Object retryState = ((java.util.Map) retriesField.get(spyProxy)).get(voteId); + java.lang.reflect.Field timeField = retryState.getClass().getDeclaredField("time"); + timeField.setAccessible(true); + timeField.setLong(retryState, 123456789L); + spyProxy.setAvailableServers("Server1", "Server2", "Server3"); + spyProxy.vote("Player", "Service", true, false, 0L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + assertEquals("123456789", spyProxy.getLastVoteEnvelope().getFields().get(VotingPluginWire.K_TIME)); + verify(spyProxy).addVoteParty(); + verify(voteCache, Mockito.times(3)).addServerVoteDurably(Mockito.anyString(), Mockito.any()); + } + + @Test + void runtimeReplacementWaitsForLiveVoteRetryToSettle() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())) + .thenReturn(false, true, true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + IllegalStateException blocked = assertThrows(IllegalStateException.class, + spyProxy::prepareForRuntimeReplacement); + assertTrue(blocked.getMessage().contains("Live vote retries")); + + spyProxy.setAvailableServers("Server1", "Server2", "Server3"); + spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + verify(spyProxy).addVoteParty(); + } + + @Test + void finalShutdownMovesLiveVoteRetryIntoDurableOutbox() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())) + .thenReturn(false, true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.retainOnlineVoteForPersistenceRetry(Mockito.anyString(), Mockito.any())) + .thenReturn(true); + Mockito.when(voteCache.retainServerVoteForPersistenceRetry(Mockito.anyString(), Mockito.any())) + .thenReturn(true); + Mockito.when(voteCache.retryPendingVotePersistence()).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + spyProxy.onDisable(false); + + verify(voteCache).retryPendingVotePersistence(); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retries.setAccessible(true); + assertTrue(((java.util.Map) retries.get(spyProxy)).isEmpty()); + } + + @Test + void runtimeRetryPersistsAckOutboxWhoseInitialInsertFailedBeforePublishing() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.tryRemoveOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.retryPendingVotePersistence()).thenReturn(true); + Mockito.when(voteCache.updateTimeVote(Mockito.any())).thenReturn(true); + Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addTimeVoteToCache(Mockito.any())).thenAnswer(new org.mockito.stubbing.Answer() { + private int attempts; + + @Override + public Boolean answer(org.mockito.invocation.InvocationOnMock invocation) { + VoteTimeQueue outbox = invocation.getArgument(0); + if (++attempts == 1) return false; + queue.add(outbox); + return true; + } + }); + Mockito.when(votingPluginProxy.getConfig().getMultiProxySupport()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getMultiProxyOneGlobalReward()).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Proxy2")); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Proxy2", "ProxyLegacy"))); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + Mockito.doReturn(true).when(spyProxy).checkVoteDelay(Mockito.anyString(), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.anyBoolean()); + Mockito.when(proxyMySQL.getExactQuery(Mockito.any())).thenReturn(new java.util.ArrayList<>()); + spyProxy.setAvailableServers(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + for (java.util.Map.Entry field : java.util.Map.of( + "player", "Player", "service", "Service", "uuid", + "00000000-0000-0000-0000-000000000001", "time", 100L, "realVote", true, + "requestIdentity", "player\u0000service").entrySet()) { + java.lang.reflect.Field target = retryType.getDeclaredField(field.getKey()); + target.setAccessible(true); + target.set(retry, field.getValue()); + } + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retries = + (java.util.Map) retriesField.get(spyProxy); + retries.put(voteId, retry); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + @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()); + 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 + .getDeclaredMethod("settleLiveVoteRetriesForFinalShutdown"); + settle.setAccessible(true); + assertFalse((Boolean) settle.invoke(spyProxy)); + + org.mockito.ArgumentCaptor outboxes = org.mockito.ArgumentCaptor.forClass(VoteTimeQueue.class); + verify(voteCache, Mockito.atLeast(2)).addTimeVoteToCache(outboxes.capture()); + assertTrue(outboxes.getAllValues().stream().allMatch(outbox -> voteId.equals(outbox.getVoteId()))); + assertEquals(1, queue.size()); + assertEquals(voteId, queue.element().getVoteId()); + assertEquals(java.util.Set.of("proxy2"), queue.element().getMultiProxyRecipients()); + } + + @Test + void rejectedLegacyOnlyMultiProxySendKeepsLiveVoteForRetry() throws Exception { + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("ProxyLegacy"))); + Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any())).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retries = + (java.util.Map) retriesField.get(votingPluginProxy); + retries.put(voteId, retry); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + + assertFalse((Boolean) begin.invoke(votingPluginProxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + org.mockito.ArgumentCaptor envelope = org.mockito.ArgumentCaptor.forClass(JsonEnvelope.class); + verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(envelope.capture(), + Mockito.eq(java.util.Set.of("ProxyLegacy"))); + assertEquals("", envelope.getValue().getFields() + .getOrDefault(VotingPluginWire.K_MULTI_PROXY_ORIGIN, "")); + } + + @Test + void capabilityDiscoveryDefersVoteUntilExpiryThenSendsOneLegacyCopy() throws Exception { + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Candidate"))); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()) + .thenReturn(java.util.Set.of("Candidate"), java.util.Collections.emptySet()); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addTimeVoteToCache(Mockito.any())).thenAnswer(invocation -> { + queue.add(invocation.getArgument(0)); + return true; + }); + Mockito.when(voteCache.updateTimeVote(Mockito.any())).thenReturn(true); + Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any())) + .thenAnswer(invocation -> { + assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty(), + "legacy recipients must be durably cleared before the non-deduplicating send"); + return true; + }); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + votingPluginProxy.setSchedulerForTest(scheduler); + VotingPluginProxyTestImpl proxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(proxy).getVoteCacheHandler(); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retries = + (java.util.Map) retriesField.get(proxy); + retries.put(voteId, retry); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + + assertFalse((Boolean) begin.invoke(proxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + assertEquals(1, queue.size()); + assertTrue(queue.element().isMultiProxyCapabilityDiscoveryPending()); + assertTrue(queue.element().isProcessed()); + verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L), + Mockito.eq(java.util.concurrent.TimeUnit.SECONDS)); + + assertTrue((Boolean) begin.invoke(proxy, retry, queue.element(), "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.eq(java.util.Set.of("Candidate"))); + assertFalse(queue.element().isMultiProxyCapabilityDiscoveryPending()); + assertTrue(queue.element().isMultiProxyForwardingHandled(), + "the durable discovery fence must be retired before reporting the legacy fallback complete"); + assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty()); + assertTrue((Boolean) begin.invoke(proxy, retry, queue.element(), "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + verify(multiProxyHandler, Mockito.times(1)).sendMultiProxyEnvelopeAccepted(Mockito.any(), + Mockito.eq(java.util.Set.of("Candidate"))); + verify(multiProxyHandler).renewMultiProxyVoteCapabilityDiscoveryIfDue(); + } + + @Test + void capabilityDiscoveryPersistenceFailureCannotFallThroughToLegacySend() throws Exception { + Mockito.when(multiProxyHandler.isMultiProxyVoteCapabilityRecoveryBlocked()).thenReturn(false, true); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Candidate"))); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + + assertFalse((Boolean) begin.invoke(votingPluginProxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + } + + @Test + void capabilityRecoveryBlockPreventsCreatingAMixedVersionAcknowledgementOutbox() throws Exception { + Mockito.when(multiProxyHandler.isMultiProxyVoteCapabilityRecoveryBlocked()).thenReturn(true); + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + + assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + verify(multiProxyHandler, never()).announceMultiProxyVoteCapability(); + verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + verify(voteCache, never()).addTimeVoteToCache(Mockito.any()); + } + + @Test + void expiredCapableMultiProxyPeerStaysInDurableOutboxUntilRenewal() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addTimeVoteToCache(Mockito.any())).thenAnswer(invocation -> { + queue.add(invocation.getArgument(0)); + return true; + }); + Mockito.when(voteCache.updateTimeVote(Mockito.any())).thenReturn(true); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()) + .thenReturn(java.util.Set.of("Capable")); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Capable"))); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retries = + (java.util.Map) retriesField.get(spyProxy); + retries.put(voteId, retry); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + + assertFalse((Boolean) begin.invoke(spyProxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + assertEquals(1, queue.size()); + assertEquals(java.util.Set.of("capable"), queue.element().getMultiProxyRecipients()); + assertTrue(queue.element().getMultiProxyLegacyPendingRecipients().isEmpty()); + verify(multiProxyHandler, Mockito.never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + } + + @Test + void deferredDurableMultiProxyLeaseRetrySchedulesThePersistedOutbox() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addTimeVoteToCache(Mockito.any())).thenAnswer(invocation -> { + queue.add(invocation.getArgument(0)); + return true; + }); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()) + .thenReturn(java.util.Set.of("Capable")); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Capable"))); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + votingPluginProxy.setSchedulerForTest(scheduler); + VotingPluginProxyTestImpl proxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(proxy).getVoteCacheHandler(); + + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retries = + (java.util.Map) retriesField.get(proxy); + retries.put(voteId, retry); + java.lang.reflect.Method begin = VotingPluginProxy.class.getDeclaredMethod("beginMultiProxyForwarding", + retryType, VoteTimeQueue.class, String.class, String.class, String.class, long.class, + boolean.class, com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class); + begin.setAccessible(true); + + assertFalse((Boolean) begin.invoke(proxy, retry, null, "Player", + "00000000-0000-0000-0000-000000000001", "Service", 100L, true, null)); + assertEquals(1, queue.size()); + verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L), + Mockito.eq(java.util.concurrent.TimeUnit.SECONDS)); + } + + @Test + void persistedOutboxWaitsForFreshCapabilityLeaseAfterRestart() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue outbox = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + outbox.requireMultiProxyAcknowledgements("Proxy1", java.util.Set.of("Capable")); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(outbox); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + // A restart loses in-memory capability knowledge, but the durable outbox + // still records that Capable requires acknowledgements. Do not publish the + // reliable envelope until a new capability exchange restores its lease. + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()) + .thenReturn(java.util.Collections.emptySet(), java.util.Set.of("Capable")); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.processQueue(); + + verify(multiProxyHandler).renewMultiProxyVoteCapabilityIfDue(); + verify(multiProxyHandler, Mockito.never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + assertTrue(queue.contains(outbox)); + + spyProxy.processQueue(); + + verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.eq(java.util.Set.of("capable"))); + assertTrue(queue.contains(outbox)); + } + + @Test + void deferredPersistedDurableOutboxSchedulesItsOwnLeaseRetry() throws Exception { + VoteTimeQueue outbox = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + outbox.requireMultiProxyAcknowledgements("Proxy1", java.util.Set.of("Capable")); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Collections.emptySet()); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + votingPluginProxy.setSchedulerForTest(scheduler); + java.lang.reflect.Method retry = VotingPluginProxy.class.getDeclaredMethod("retryDurableMultiProxyOutbox", + VoteTimeQueue.class); + retry.setAccessible(true); + + assertFalse((Boolean) retry.invoke(votingPluginProxy, outbox)); + + verify(multiProxyHandler).renewMultiProxyVoteCapabilityIfDue(); + verify(multiProxyHandler, Mockito.never()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + verify(scheduler).schedule(Mockito.any(Runnable.class), Mockito.eq(5L), + Mockito.eq(java.util.concurrent.TimeUnit.SECONDS)); + } + + @Test + void restartResumesDurableDiscoveryOutboxBeforePublishingIt() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue outbox = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + outbox.requireMultiProxyAcknowledgements("Proxy1", java.util.Collections.emptySet()); + outbox.setMultiProxyCapabilityDiscoveryPending(true); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(outbox); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.updateTimeVote(Mockito.any())).thenReturn(true); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Capable")); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityRenewal()) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipientsAwaitingCapabilityDiscovery()) + .thenReturn(java.util.Collections.emptySet()); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Capable"))); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + VotingPluginProxyTestImpl proxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(proxy).getVoteCacheHandler(); + + proxy.processQueue(); + + assertFalse(outbox.isMultiProxyCapabilityDiscoveryPending()); + assertEquals(java.util.Set.of("capable"), outbox.getMultiProxyRecipients()); + verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.eq(java.util.Set.of("capable"))); + assertTrue(queue.contains(outbox)); + } + + @Test + void completedAckOutboxKeepsFenceForAlreadyScheduledListenerRetry() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + VoteTimeQueue outbox = new VoteTimeQueue(voteId, "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + outbox.setMultiProxyForwardingRequired(true); + outbox.setMultiProxyForwardingHandled(true); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(outbox); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.removeTimeVote(outbox)).thenAnswer(invocation -> queue.remove(outbox)); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor constructor = retryType.getDeclaredConstructor(); + constructor.setAccessible(true); + Object retry = constructor.newInstance(); + java.lang.reflect.Field retriesField = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retriesField.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retries = + (java.util.Map) retriesField.get(spyProxy); + retries.put(voteId, retry); + + spyProxy.processQueue(); + + assertTrue(queue.isEmpty()); + assertTrue(retries.containsKey(voteId)); + } + + @Test + void pendingAckOutboxDoesNotBlockLaterCompletedQueueRows() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue pendingAck = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + pendingAck.requireMultiProxyAcknowledgements("Proxy1", java.util.Set.of("Proxy2")); + VoteTimeQueue completed = new VoteTimeQueue(java.util.UUID.randomUUID(), "Other", "Service", 101L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000002"); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(pendingAck); + queue.add(completed); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.removeTimeVote(completed)).thenAnswer(invocation -> queue.remove(completed)); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Proxy2")); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.processQueue(); + + assertEquals(java.util.List.of(pendingAck), new java.util.ArrayList<>(queue)); + verify(multiProxyHandler).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.eq(java.util.Set.of("proxy2"))); + verify(voteCache).removeTimeVote(completed); + } + + @Test + void retainedHttpRolloverRowWithPendingBroadcastIdsDoesNotStarveLaterQueueRows() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue retained = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, "player-uuid", + java.util.Map.of("Server1", "00000000-0000-0000-0000-000000000123")); + VoteTimeQueue completed = new VoteTimeQueue(java.util.UUID.randomUUID(), "Other", "Service", 101L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(retained); + queue.add(completed); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.removeTimeVote(completed)).thenAnswer(invocation -> queue.remove(completed)); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.processQueue(); + + assertEquals(1, queue.size()); + assertEquals(java.util.List.of(retained), new java.util.ArrayList<>(queue)); + assertTrue(retained.hasPendingHttpBroadcastDeliveryIds()); + verify(voteCache, Mockito.never()).removeTimeVote(retained); + verify(voteCache).removeTimeVote(completed); + } + + @Test + void completedAckOutboxWaitsForDurableFenceRetirementBeforeFinishing() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + VoteTimeQueue outbox = new VoteTimeQueue(voteId, "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + outbox.requireMultiProxyAcknowledgements("Proxy1", java.util.Set.of("Proxy2")); + assertTrue(outbox.acknowledgeMultiProxyRecipient("Proxy2")); + Mockito.when(voteCache.updateTimeVote(outbox)).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Class retryType = Class.forName("com.bencodez.votingplugin.proxy.VotingPluginProxy$LiveVoteRetryState"); + java.lang.reflect.Constructor retryConstructor = retryType.getDeclaredConstructor(); + retryConstructor.setAccessible(true); + Object retry = retryConstructor.newInstance(); + java.lang.reflect.Method finish = VotingPluginProxy.class.getDeclaredMethod("finishMultiProxyRetirement", + retryType, VoteTimeQueue.class); + finish.setAccessible(true); + + assertFalse((Boolean) finish.invoke(spyProxy, retry, outbox)); + assertFalse(outbox.isMultiProxyForwardingHandled()); + verify(multiProxyHandler).requestMultiProxyVoteRetirement(voteId, "Proxy1", "proxy2"); + + assertTrue(outbox.acknowledgeMultiProxyRetirement("Proxy2")); + assertTrue((Boolean) finish.invoke(spyProxy, retry, outbox)); + assertTrue(outbox.isMultiProxyForwardingHandled()); + verify(voteCache).updateTimeVote(outbox); + } + + @Test + void completedAckOutboxPersistsAckProgressBeforeRequestingRetirement() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + VoteTimeQueue outbox = new VoteTimeQueue(voteId, "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + outbox.requireMultiProxyAcknowledgements("Proxy1", java.util.Set.of("Proxy2")); + assertTrue(outbox.acknowledgeMultiProxyRecipient("Proxy2")); + outbox.setDeliveryStateDirty(true); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(outbox); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.updateTimeVote(outbox)).thenReturn(false); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.processQueue(); + + verify(voteCache).updateTimeVote(outbox); + verify(multiProxyHandler, never()).requestMultiProxyVoteRetirement(Mockito.any(), Mockito.anyString(), + Mockito.anyString()); + } + + @Test + void receiverKeepsCompletionFenceUntilItsProcessedQueueRowIsRemoved() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + VoteTimeQueue received = new VoteTimeQueue(voteId, "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + received.setMultiProxyOrigin("Primary"); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(received); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.removeTimeVote(received)).thenReturn(false, true); + Mockito.when(voteCache.removeMultiProxyVoteCompletion(voteId)).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + java.lang.reflect.Method retire = VotingPluginProxy.class.getDeclaredMethod( + "handleMultiProxyVoteRetirementRequest", java.util.UUID.class, String.class); + retire.setAccessible(true); + + retire.invoke(spyProxy, voteId, "Primary"); + verify(voteCache, never()).removeMultiProxyVoteCompletion(voteId); + verify(multiProxyHandler, never()).acknowledgeMultiProxyVoteRetirement(voteId, "Primary"); + + retire.invoke(spyProxy, voteId, "Primary"); + verify(voteCache).removeMultiProxyVoteCompletion(voteId); + verify(multiProxyHandler).acknowledgeMultiProxyVoteRetirement(voteId, "Primary"); + } + + @Test + void finalShutdownFencesQueuedVoteBeforeClearingLiveRetry() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + VoteTimeQueue queued = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", false, + "00000000-0000-0000-0000-000000000001"); + queue.add(queued); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(false, true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.retainOnlineVoteForPersistenceRetry(Mockito.anyString(), Mockito.any())) + .thenReturn(true); + Mockito.when(voteCache.retainServerVoteForPersistenceRetry(Mockito.anyString(), Mockito.any())) + .thenReturn(true); + Mockito.when(voteCache.retryPendingVotePersistence()).thenReturn(true); + Mockito.when(voteCache.updateTimeVote(queued)).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider"); + decider.setAccessible(true); + decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers, + server -> true, server -> false)); + + spyProxy.processQueue(); + assertFalse(queued.isProcessed()); + spyProxy.onDisable(false); + + assertTrue(queued.isProcessed()); + verify(voteCache).updateTimeVote(queued); + verify(spyProxy).addVoteParty(); + } + + @Test + void finalShutdownRetainsUnacknowledgedMultiProxyForwardingForRetry() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + VoteTimeQueue queued = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", false, + "00000000-0000-0000-0000-000000000001"); + queue.add(queued); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.retryPendingVotePersistence()).thenReturn(true); + // The forwarding fence must be durable before later completion persistence + // fails, so shutdown can retry the completion without publishing again. + Mockito.when(voteCache.updateTimeVote(queued)).thenReturn(true, false); + Mockito.when(voteCache.removeTimeVote(queued)).thenReturn(false); + Mockito.when(voteCache.markTimeVoteCompletedDurably(queued)).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getMultiProxySupport()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getMultiProxyOneGlobalReward()).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Proxy2")); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + Mockito.doReturn(true).when(spyProxy).checkVoteDelay(Mockito.anyString(), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.anyBoolean()); + Mockito.when(proxyMySQL.getExactQuery(Mockito.any())) + .thenReturn(new java.util.ArrayList<>()); + java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider"); + decider.setAccessible(true); + decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers, + server -> true, server -> false)); + + spyProxy.processQueue(); + assertTrue(queued.isProcessed()); + // A fire-and-forget transport invocation is not a receiver completion ACK. + assertFalse(queued.isMultiProxyForwardingHandled()); + spyProxy.onDisable(false); + + verify(multiProxyHandler, Mockito.atLeastOnce()).sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any()); + } + + @Test + void reconstructedQueuedVoteRetainsUnacknowledgedMultiProxyDeliveryState() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + VoteTimeQueue queued = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", false, + "00000000-0000-0000-0000-000000000001"); + queue.add(queued); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateTimeVote(Mockito.any())).thenReturn(true, false, true); + Mockito.when(multiProxyHandler.sendMultiProxyEnvelopeAccepted(Mockito.any(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.removeTimeVote(queued)).thenReturn(false); + Mockito.when(voteCache.markTimeVoteCompletedDurably(queued)).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getMultiProxySupport()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getMultiProxyOneGlobalReward()).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getProxyServerName()).thenReturn("Proxy1"); + Mockito.when(multiProxyHandler.getMultiProxyVoteRecipients()).thenReturn(java.util.Set.of("Proxy2")); + Mockito.when(multiProxyHandler.getConfiguredMultiProxyVoteRecipients()) + .thenReturn(new java.util.LinkedHashSet<>(java.util.Set.of("Proxy2", "ProxyLegacy"))); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + Mockito.doReturn(true).when(spyProxy).checkVoteDelay(Mockito.anyString(), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.anyBoolean()); + Mockito.when(proxyMySQL.getExactQuery(Mockito.any())).thenReturn(new java.util.ArrayList<>()); + java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider"); + decider.setAccessible(true); + decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers, + server -> true, server -> false)); + + spyProxy.processQueue(); + assertFalse(queued.isMultiProxyForwardingHandled()); + VoteTimeQueue restored = new VoteTimeQueue(queued.getVoteId(), queued.getName(), queued.getService(), + queued.getTime(), queued.isProxyBroadcastHandled(), queued.getBroadcastTargets(), + queued.getBroadcastForwardedServers(), queued.getTotals(), queued.isProcessed(), + queued.isMultiProxyForwardingHandled(), queued.getUuid(), queued.getHttpBroadcastDeliveryIds()); + restored.setMultiProxyForwardingRequired(queued.isMultiProxyForwardingRequired()); + restored.setRealVote(queued.isRealVote()); + restored.setMultiProxyOrigin(queued.getMultiProxyOrigin()); + restored.setMultiProxyRecipients(queued.getMultiProxyRecipients()); + restored.setMultiProxyAcknowledgedServers(queued.getMultiProxyAcknowledgedServers()); + // The failed pre-publication update means no legacy copy was sent and durable + // storage still contains the pending recipient for restart recovery. + restored.setMultiProxyLegacyPendingRecipients(java.util.Set.of("ProxyLegacy")); + queue.clear(); + queue.add(restored); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retries.setAccessible(true); + ((java.util.Map) retries.get(spyProxy)).clear(); + + spyProxy.processQueue(); + + @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()); + assertEquals(1, recipients.getAllValues().stream() + .filter(value -> new java.util.HashSet<>(value).equals(java.util.Set.of("ProxyLegacy"))).count()); + assertEquals(1, recipients.getAllValues().stream() + .filter(value -> new java.util.HashSet<>(value).equals(java.util.Set.of("proxy2"))).count()); + } + + @Test + void forwardedMultiProxyVoteRetriesWithStableWireIdWithoutRepeatingPartySideEffect() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(false, true); + Mockito.when(voteCache.retainServerVoteForPersistenceRetry(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.markMultiProxyVoteCompletedDurably(Mockito.any())).thenReturn(true); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + Mockito.when(scheduler.isShutdown()).thenReturn(false); + votingPluginProxy.setSchedulerForTest(scheduler); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + org.mockito.ArgumentCaptor scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler, Mockito.atLeastOnce()).schedule(scheduled.capture(), Mockito.anyLong(), + Mockito.any(java.util.concurrent.TimeUnit.class)); + Runnable voteRetry = scheduled.getAllValues().stream() + .filter(task -> task.getClass().getName().contains("MultiProxyVoteRetry")) + .findFirst().orElseThrow(); + voteRetry.run(); + // A duplicate socket/Redis delivery of the completed envelope is ignored. + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + verify(spyProxy).addVoteParty(); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retries.setAccessible(true); + assertTrue(((java.util.Map) retries.get(spyProxy)).isEmpty()); + } + + @Test + void forwardedMultiProxyVoteRetriesCompletionRecordWithoutRepeatingVoteSideEffects() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.tryRemoveOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.markMultiProxyVoteCompletedDurably(Mockito.any())).thenReturn(false, true); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + Mockito.when(scheduler.isShutdown()).thenReturn(false); + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setAvailableServers(); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + org.mockito.ArgumentCaptor scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).schedule(scheduled.capture(), Mockito.anyLong(), + Mockito.any(java.util.concurrent.TimeUnit.class)); + scheduled.getValue().run(); + + verify(spyProxy).addVoteParty(); + verify(voteCache, Mockito.times(2)).markMultiProxyVoteCompletedDurably(voteId); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries"); + retries.setAccessible(true); + assertTrue(((java.util.Map) retries.get(spyProxy)).isEmpty()); + } + + @Test + void restartedReceiverSkipsDurablyCompletedMultiProxyVote() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.UUID voteId = java.util.UUID.randomUUID(); + Mockito.when(voteCache.hasMultiProxyVoteCompletion(voteId)).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + verify(spyProxy, never()).addVoteParty(); + } + + @Test + void overflowForwardedVoteIsSpilledToDurableReplayQueueInsteadOfDropped() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addTimeVoteToCache(Mockito.any())).thenAnswer(invocation -> { + queue.add(invocation.getArgument(0)); + return true; + }); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries"); + retries.setAccessible(true); + @SuppressWarnings("unchecked") + java.util.Map retryMap = (java.util.Map) retries.get(spyProxy); + for (int i = 0; i < 1024; i++) retryMap.put(java.util.UUID.randomUUID(), null); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", false, true, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + assertEquals(1, queue.size()); + assertEquals(voteId, queue.element().getVoteId()); + assertEquals("", queue.element().getTotals()); + assertFalse(queue.element().isRealVote()); + assertEquals(1024, retryMap.size()); + } + + @Test + void finalShutdownSettlesConsumedForwardedVoteBeforeDroppingRetryState() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(false, true); + Mockito.when(voteCache.retainServerVoteForPersistenceRetry(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.retryPendingVotePersistence()).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.markMultiProxyVoteCompletedDurably(Mockito.any())).thenReturn(true); + votingPluginProxy.setSchedulerForTest(null); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + spyProxy.onDisable(false); + + verify(spyProxy).addVoteParty(); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries"); + retries.setAccessible(true); + assertTrue(((java.util.Map) retries.get(spyProxy)).isEmpty()); + } + + @Test + void finalShutdownRetainsForwardedFenceWhenCompletionCannotBePersisted() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addTimeVoteToCache(Mockito.any())).thenAnswer(invocation -> { + queue.add(invocation.getArgument(0)); + return true; + }); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.markMultiProxyVoteCompletedDurably(Mockito.any())).thenReturn(false); + votingPluginProxy.setSchedulerForTest(null); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + spyProxy.onDisable(false); + + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("multiProxyVoteRetries"); + retries.setAccessible(true); + assertTrue(((java.util.Map) retries.get(spyProxy)).isEmpty()); + assertEquals(1, queue.size()); + assertTrue(queue.element().isProcessed()); + assertTrue(queue.element().isMultiProxyCompletionPending()); + assertEquals(voteId, queue.element().getVoteId()); + java.lang.reflect.Field enabled = VotingPluginProxy.class.getDeclaredField("enabled"); + enabled.setAccessible(true); + assertFalse(enabled.getBoolean(spyProxy)); + verify(multiProxyHandler).close(); + } + + @Test + void persistedForwardedCompletionWaitsForTombstoneWithoutRepeatingVoteEffects() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + VoteTimeQueue completion = new VoteTimeQueue(voteId, "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "totals", true, + "00000000-0000-0000-0000-000000000001"); + completion.setMultiProxyOrigin("Primary"); + completion.setMultiProxyCompletionPending(true); + queue.add(completion); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.hasMultiProxyVoteCompletion(voteId)).thenReturn(false); + Mockito.when(voteCache.markMultiProxyVoteCompletedDurably(voteId)).thenReturn(true); + Mockito.when(voteCache.removeTimeVote(completion)).thenAnswer(invocation -> queue.remove(completion)); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.processQueue(); + + assertTrue(queue.isEmpty()); + verify(spyProxy, never()).addVoteParty(); + verify(multiProxyHandler).acknowledgeMultiProxyVote(voteId, "Primary"); + } + + @Test + void failedReceiverCompletionWriteResumesWithoutRepeatingVoteEffects() throws Exception { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + VoteTimeQueue received = new VoteTimeQueue(voteId, "Player", "Service", 100L, false, + java.util.Collections.emptySet(), java.util.Collections.emptySet(), "", false, + "00000000-0000-0000-0000-000000000001"); + received.setMultiProxyOrigin("Primary"); + queue.add(received); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.updateTimeVote(received)).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.tryRemoveOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.hasMultiProxyVoteCompletion(voteId)).thenReturn(false); + Mockito.when(voteCache.markMultiProxyVoteCompletedDurably(voteId)).thenReturn(false, true); + Mockito.when(voteCache.removeTimeVote(received)).thenAnswer(invocation -> queue.remove(received)); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + java.lang.reflect.Field decider = VotingPluginProxy.class.getDeclaredField("proxyBroadcastDecider"); + decider.setAccessible(true); + decider.set(spyProxy, new ProxyBroadcastDecider(spyProxy::getConfig, spyProxy::getAllAvailableServers, + server -> true, server -> false)); + Mockito.doNothing().when(spyProxy).addVoteParty(); + Mockito.doReturn(true).when(spyProxy).checkVoteDelay(Mockito.anyString(), Mockito.anyString(), + Mockito.anyString(), Mockito.any(), Mockito.anyBoolean()); + Mockito.when(proxyMySQL.containsKeyQuery(Mockito.anyString())).thenReturn(true); + Mockito.when(proxyMySQL.getExactQuery(Mockito.any())).thenReturn(new java.util.ArrayList<>()); + spyProxy.setAvailableServers(); + + spyProxy.processQueue(); + + assertTrue(received.isProcessed()); + assertTrue(received.isMultiProxyCompletionPending()); + assertEquals(1, queue.size()); + verify(spyProxy).addVoteParty(); + + spyProxy.processQueue(); + + assertTrue(queue.isEmpty()); + verify(spyProxy).addVoteParty(); + verify(multiProxyHandler).acknowledgeMultiProxyVote(voteId, "Primary"); + } + + @Test + void runtimeReplacementRejectsForwardedRetryBeforeLiveVoteStateExists() throws Exception { + votingPluginProxy.setProxyMySQL(null); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + java.lang.reflect.Method receive = VotingPluginProxy.class.getDeclaredMethod("receiveMultiProxyVote", + String.class, String.class, boolean.class, boolean.class, long.class, + com.bencodez.votingplugin.proxy.VoteTotalsSnapshot.class, String.class, java.util.UUID.class); + receive.setAccessible(true); + + receive.invoke(spyProxy, "Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID()); + + java.lang.reflect.Field liveRetries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + liveRetries.setAccessible(true); + assertTrue(((java.util.Map) liveRetries.get(spyProxy)).isEmpty()); + assertThrows(IllegalStateException.class, spyProxy::prepareForRuntimeReplacement); + } + + @Test + void preparedRuntimeDoesNotStartAReplacementWindowVote() throws Exception { + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doNothing().when(spyProxy).addVoteParty(); + + spyProxy.prepareForRuntimeReplacement(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", java.util.UUID.randomUUID())); + verify(spyProxy, never()).addVoteParty(); + java.lang.reflect.Field retries = VotingPluginProxy.class.getDeclaredField("liveVoteRetries"); + retries.setAccessible(true); + assertTrue(((java.util.Map) retries.get(spyProxy)).isEmpty()); + } + + @Test + void durableRewardJournalOwnerRecreatesMissingServerOutbox() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote persistedOwner = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals"); + persistedOwner.setHttpDeliveryId("__vp_reward_target__:Server1", + "00000000-0000-0000-0000-000000000199"); + OfflineBungeeVote owner = new OfflineBungeeVote(persistedOwner.getVoteId(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, false, java.util.Collections.emptySet(), + java.util.Collections.emptySet(), false, + OfflineBungeeVote.decodeHttpDeliveryIds(persistedOwner.encodeHttpDeliveryIds()), + java.util.Collections.emptyMap()); + owner.setDeliveryStateDirty(false); + Mockito.when(voteCache.retryPendingVotePersistence()).thenReturn(true); + Mockito.when(voteCache.getOnlineVoteUUIDs()).thenReturn(java.util.Set.of("player-uuid")); + Mockito.when(voteCache.getOnlineVotes("player-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(owner))); + Mockito.when(voteCache.addServerVoteDurably(Mockito.eq("Server1"), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote("player-uuid", owner)).thenReturn(true); + Mockito.when(voteCache.tryRemoveOnlineVote("player-uuid", owner)).thenReturn(true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.retryPendingOnlineBroadcasts(); + + assertTrue(owner.isRewardDelivered()); + verify(voteCache).addServerVoteDurably(Mockito.eq("Server1"), Mockito.argThat(vote -> + "00000000-0000-0000-0000-000000000199".equals(vote.getHttpDeliveryId("Server1")))); + verify(voteCache).updateOnlineVote("player-uuid", owner); + verify(voteCache, Mockito.atLeastOnce()).tryRemoveOnlineVote("player-uuid", owner); + } + + @Test + void liveVoteRetryKeepsTotalsInputAfterVotePartyFailure() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(proxyMySQL.containsKeyQuery(Mockito.anyString())).thenReturn(true); + Mockito.when(proxyMySQL.getExactQuery(Mockito.any())).thenReturn(new java.util.ArrayList<>()); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + votingPluginProxy.setStandaloneBroadcastForwarding(false); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doThrow(new IllegalStateException("party persistence failed")).when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + assertThrows(IllegalArgumentException.class, + () -> spyProxy.vote("OtherPlayer", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000002", voteId)); + spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + verify(spyProxy).addVoteParty(); + verify(proxyMySQL).update(Mockito.anyString(), Mockito.any(java.util.ArrayList.class)); + } + + @Test + void liveVoteRetryDoesNotResendCompletedServerReward() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true, false, true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setStableHttpDeliveryResults(true, false, true); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + assertEquals(3, spyProxy.getAttemptedVotePartyDeliveryIds().size()); + verify(spyProxy).addVoteParty(); + } + + @Test + void liveVoteRetryRetainsRecoveredHttpDeliveryId() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + Mockito.when(votingPluginProxy.getConfig().getSendVotesToAllServers()).thenReturn(true); + Mockito.when(voteCache.addServerVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateServerVote(Mockito.anyString(), Mockito.any())).thenReturn(true, false, true); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any())).thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any())).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setStableHttpDeliveryResults(true, true); + String recoveredId = "00000000-0000-0000-0000-000000000198"; + votingPluginProxy.failNextStableHttpSend(recoveredId); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + java.util.UUID voteId = java.util.UUID.randomUUID(); + + assertThrows(VotingPluginProxy.VoteRetryException.class, + () -> spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId)); + spyProxy.vote("Player", "Service", true, false, 100L, null, + "00000000-0000-0000-0000-000000000001", voteId); + + assertEquals(recoveredId, spyProxy.getAttemptedVotePartyDeliveryIds().get(2)); + } + + @Test + void standaloneBackendCacheRowDoesNotCopyCanonicalMultiTargetState() { + OfflineBungeeVote row = votingPluginProxy.createCachedRewardVoteForTest( + java.util.UUID.randomUUID(), "Player", "player-uuid", "Service", 100L, true, "totals", true); + + assertTrue(row.isBroadcastForwarded()); + assertFalse(row.isProxyBroadcastHandled()); + assertTrue(row.getBroadcastTargets().isEmpty()); + assertTrue(row.getBroadcastForwardedServers().isEmpty()); + assertTrue(row.getHttpBroadcastDeliveryIds().isEmpty()); + } + + @Test + void queuedRolloverReplayReusesStableStandaloneBroadcastDeliveryId() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + String deliveryId = "00000000-0000-0000-0000-000000000172"; + VoteTimeQueue queued = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, true, + java.util.Set.of("Server1", "Server2"), java.util.Set.of("Server1"), "totals", false, + "player-uuid", java.util.Map.of("Server2", deliveryId)); + queue.add(queued); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.addOnlineVoteDurably(Mockito.anyString(), Mockito.any(OfflineBungeeVote.class))) + .thenReturn(true); + Mockito.when(voteCache.updateOnlineVote(Mockito.anyString(), Mockito.any(OfflineBungeeVote.class))) + .thenReturn(true); + Mockito.when(voteCache.updateTimeVote(queued)).thenReturn(false, true); + java.util.concurrent.atomic.AtomicInteger removeAttempts = new java.util.concurrent.atomic.AtomicInteger(); + Mockito.when(voteCache.removeTimeVote(queued)).thenAnswer(invocation -> + removeAttempts.getAndIncrement() == 0 ? false : queue.remove(queued)); + Mockito.when(votingPluginProxy.getConfig().getPrimaryServer()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBungeeManageTotals()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getProxyBroadcastEnabled()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getProxyBroadcastOfflineMode()).thenReturn("FORWARD"); + votingPluginProxy.setGlobalMessageProxyHandlerForTest( + Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class)); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + Mockito.doNothing().when(spyProxy).addVoteParty(); + spyProxy.processQueue(); + + assertFalse(queue.isEmpty()); + assertThrows(IllegalStateException.class, spyProxy::prepareForRuntimeReplacement); + verify(voteCache).removeTimeVote(queued); + + spyProxy.processQueue(); + + assertTrue(queue.isEmpty()); + assertEquals(deliveryId, spyProxy.getAttemptedVotePartyDeliveryIds().get(0)); + assertEquals(2, spyProxy.getAttemptedVotePartyDeliveryIds().size()); + assertFalse(deliveryId.equals(spyProxy.getAttemptedVotePartyDeliveryIds().get(1))); + verify(voteCache, Mockito.times(2)).updateTimeVote(queued); + verify(voteCache, Mockito.times(2)).removeTimeVote(queued); + verify(spyProxy).addVoteParty(); + verify(voteCache, never()).addOnlineVote(Mockito.anyString(), Mockito.any(OfflineBungeeVote.class)); + } + + @Test + void durableCompletionTombstoneSkipsTimedVoteSideEffectsAfterRestart() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + VoteTimeQueue queued = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, false, + java.util.Set.of(), java.util.Set.of(), "totals", false, "player-uuid"); + queue.add(queued); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.hasTimeVoteCompletion(queued)).thenReturn(true); + Mockito.when(voteCache.updateTimeVote(queued)).thenReturn(true); + Mockito.when(voteCache.removeTimeVote(queued)).thenAnswer(invocation -> queue.remove(queued)); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.processQueue(); + + assertTrue(queue.isEmpty()); + verify(spyProxy, never()).addVoteParty(); + verify(voteCache).clearTimeVoteCompletion(queued); + } + @Test void testAddVoteParty() { // Initial votePartyVotes should be 0 assertEquals(0, votingPluginProxy.getVotePartyVotes()); - // Spy on the votingPluginProxy object - VotingPluginProxy spyProxy = Mockito.spy(votingPluginProxy); - doNothing().when(spyProxy).checkVoteParty(); + // Spy on the votingPluginProxy object + VotingPluginProxy spyProxy = Mockito.spy(votingPluginProxy); + doNothing().when(spyProxy).checkVoteParty(); + + // Add one vote party + spyProxy.addCurrentVotePartyVotes(1); + + // Verify that votePartyVotes increased by 1 + assertEquals(1, spyProxy.getVotePartyVotes()); + } + + @Test + void testAddCurrentVotePartyVotes() { + // Initial votePartyVotes should be 0 + assertEquals(0, votingPluginProxy.getVotePartyVotes()); + + // Add 3 votes + votingPluginProxy.addCurrentVotePartyVotes(3); + assertEquals(3, votingPluginProxy.getVotePartyVotes()); + + // Add 2 more votes + votingPluginProxy.addCurrentVotePartyVotes(2); + assertEquals(5, votingPluginProxy.getVotePartyVotes()); + } + + @Test + void rejectedHttpVotePartyRewardRemainsPendingUntilAccepted() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + votingPluginProxy.sendVoteParty("Server1"); + assertEquals(1, votingPluginProxy.getVoteCachePendingVotePartyRewardIds("Server1").size()); + String deliveryId = votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(0); + + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + votingPluginProxy.retryPendingVotePartyRewardsForTest(); + assertEquals(deliveryId, votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(1)); + assertEquals(1, votingPluginProxy.getVoteCachePendingVotePartyRewardIds("Server1").size()); + votingPluginProxy.acknowledgeVotePartyDeliveryForTest("server1", deliveryId); + assertEquals(0, votingPluginProxy.getVoteCachePendingVotePartyRewardIds("Server1").size()); + } + + @Test + void failedVotePartyAcknowledgementSaveRestoresPendingMarker() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + votingPluginProxy.sendVoteParty("Server1"); + String deliveryId = votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(0); + + votingPluginProxy.failNextVoteCacheSave(); + assertThrows(IllegalStateException.class, + () -> votingPluginProxy.acknowledgeVotePartyDeliveryForTest("server1", deliveryId)); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyRewardIds("Server1").contains(deliveryId)); + } + + @Test + void httpVotePartyStagesEveryTargetBeforeDelivery() { + Mockito.when(votingPluginProxy.getConfig().getVotePartyEnabled()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getVotePartySendToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getVotePartyBroadcast()).thenReturn(""); + Mockito.when(votingPluginProxy.getConfig().getVotePartyBungeeCommands()).thenReturn(java.util.List.of()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + votingPluginProxy.setVotePartyVotes(1); + votingPluginProxy.setCurrentVotePartyVotesRequired(1); + + votingPluginProxy.checkVoteParty(); + + assertEquals(1, votingPluginProxy.getVoteCachePendingVotePartyRewardIds("server1").size()); + assertEquals(1, votingPluginProxy.getVoteCachePendingVotePartyRewardIds("SERVER2").size()); + assertEquals(0, votingPluginProxy.getVotePartyVotes()); + } + + @Test + void httpVotePartyRetriesJournaledProxyEffectsAfterExecutionFailure() { + configureHttpVotePartyEffects("Party time", java.util.List.of("first", "second")); + votingPluginProxy.failNextBroadcast(); + + votingPluginProxy.checkVoteParty(); + + assertEquals(0, votingPluginProxy.getVotePartyVotes()); + assertEquals("Party time", votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().broadcast()); + assertEquals(java.util.List.of("first", "second"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + assertTrue(votingPluginProxy.getBroadcasts().isEmpty()); + assertTrue(votingPluginProxy.getConsoleCommands().isEmpty()); + + assertTrue(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + assertEquals(java.util.List.of("Party time"), votingPluginProxy.getBroadcasts()); + assertEquals(java.util.List.of("first", "second"), votingPluginProxy.getConsoleCommands()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + } + + @Test + void scheduledVotePartyEffectRetryProcessesAnotherReachedThreshold() { + configureHttpVotePartyEffects("Party time", java.util.List.of()); + java.util.concurrent.ScheduledExecutorService scheduler = Mockito + .mock(java.util.concurrent.ScheduledExecutorService.class); + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.failNextBroadcast(); + + votingPluginProxy.checkVoteParty(); + votingPluginProxy.setVotePartyVotes(1); + org.mockito.ArgumentCaptor retry = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).schedule(retry.capture(), Mockito.eq(5L), + Mockito.eq(java.util.concurrent.TimeUnit.SECONDS)); + + retry.getValue().run(); + + assertEquals(java.util.List.of("Party time", "Party time"), votingPluginProxy.getBroadcasts()); + assertEquals(0, votingPluginProxy.getVotePartyVotes()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + } + + @Test + void httpVotePartyRestoresExecutedEffectWhenProgressIsNotDurable() { + configureHttpVotePartyEffects("Party time", java.util.List.of("reward all")); + votingPluginProxy.failSaveAfterNextBroadcast(); + + votingPluginProxy.checkVoteParty(); + + assertEquals(java.util.List.of("Party time"), votingPluginProxy.getBroadcasts()); + assertEquals("Party time", votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().broadcast()); + assertEquals(java.util.List.of("reward all"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + + assertTrue(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + assertEquals(java.util.List.of("Party time", "Party time"), votingPluginProxy.getBroadcasts()); + assertEquals(java.util.List.of("reward all"), votingPluginProxy.getConsoleCommands()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + } + + @Test + void httpVotePartyPersistsOrderedCommandProgress() { + configureHttpVotePartyEffects("", java.util.List.of("first", "second", "third")); + votingPluginProxy.failConsoleCommand("second"); + + votingPluginProxy.checkVoteParty(); + + assertEquals(java.util.List.of("first"), votingPluginProxy.getConsoleCommands()); + assertEquals(java.util.List.of("second", "third"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + assertTrue(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + assertEquals(java.util.List.of("first", "second", "third"), votingPluginProxy.getConsoleCommands()); + } + + @Test + void declinedVotePartyCommandRemainsPendingForRetry() { + configureHttpVotePartyEffects("", java.util.List.of("not-yet-registered")); + votingPluginProxy.declineNextVotePartyCommand(); + + votingPluginProxy.checkVoteParty(); + + assertEquals(java.util.List.of("not-yet-registered"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + assertTrue(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + assertEquals(java.util.List.of("not-yet-registered"), votingPluginProxy.getConsoleCommands()); + } + + @Test + void httpVotePartyKeepsCommandPendingUntilAsyncExecutionCompletesAndResumesReachedThreshold() { + configureHttpVotePartyEffects("", java.util.List.of("async command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + + votingPluginProxy.checkVoteParty(); + + assertEquals(java.util.List.of("async command"), votingPluginProxy.getConsoleCommands()); + assertEquals(java.util.List.of("async command"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + assertFalse(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + votingPluginProxy.setVotePartyVotes(1); + + completion.complete(null); + + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + assertEquals(java.util.List.of("async command", "async command"), + votingPluginProxy.getConsoleCommands()); + assertEquals(0, votingPluginProxy.getVotePartyVotes()); + } + + @Test + void completedCommandWithUnpersistedProgressIsQuarantinedInsteadOfReplayed() { + configureHttpVotePartyEffects("", java.util.List.of("async command", "next command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + + votingPluginProxy.checkVoteParty(); + votingPluginProxy.failNextVoteCacheSave(); + completion.complete(null); + + assertTrue(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + assertEquals(java.util.List.of("async command", "next command"), votingPluginProxy.getConsoleCommands()); + assertEquals(java.util.List.of("async command"), + votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().commands()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + } + + @Test + void httpVotePartyQuarantinesHungCommandWithoutRetryingIt() { + configureHttpVotePartyEffects("", java.util.List.of("hung command", "next command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + + votingPluginProxy.checkVoteParty(); + votingPluginProxy.runVotePartyProxyCommandTimeoutForTest(); + + assertEquals(java.util.List.of("hung command"), + votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().commands()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + assertEquals(java.util.List.of("hung command", "next command"), votingPluginProxy.getConsoleCommands()); + + completion.complete(null); + assertEquals(java.util.List.of("hung command", "next command"), votingPluginProxy.getConsoleCommands()); + assertEquals(java.util.List.of("hung command"), + votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().commands()); + } + + @Test + void runtimeReplacementQuarantinesAnInFlightVotePartyCommandBeforeHandoff() { + configureHttpVotePartyEffects("", java.util.List.of("in flight", "next command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + votingPluginProxy.checkVoteParty(); + + assertTrue(votingPluginProxy.quarantineInFlightVotePartyProxyCommandForReplacementForTest()); + + assertEquals(java.util.List.of("in flight"), + votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().commands()); + assertEquals(java.util.List.of("next command"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + completion.complete(null); + assertEquals(java.util.List.of("in flight"), votingPluginProxy.getConsoleCommands()); + } + + @Test + void finalShutdownQuarantinesAnInFlightVotePartyCommandBeforeTeardown() { + configureHttpVotePartyEffects("", java.util.List.of("in flight", "next command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + votingPluginProxy.checkVoteParty(); + + votingPluginProxy.onDisable(false); + + assertEquals(java.util.List.of("in flight"), + votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().commands()); + assertEquals(java.util.List.of("next command"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + completion.complete(null); + assertEquals(java.util.List.of("in flight"), votingPluginProxy.getConsoleCommands()); + } + + @Test + void finalShutdownRetriesTheDurableFenceBeforeTeardown() { + configureHttpVotePartyEffects("", java.util.List.of("in flight", "next command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + votingPluginProxy.checkVoteParty(); + votingPluginProxy.failNextVoteCacheSave(); + + votingPluginProxy.onDisable(false); + + assertEquals(java.util.List.of("in flight"), + votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().commands()); + assertEquals(java.util.List.of("next command"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + completion.complete(null); + assertEquals(java.util.List.of("in flight"), votingPluginProxy.getConsoleCommands()); + assertEquals(java.util.List.of("next command"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + } + + @Test + void transportReloadRetainsHttpUntilPendingVotePartyRewardsAreAcknowledged() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteCachePendingVotePartyReward("Server1", "delivery", true); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("MYSQL"); + + votingPluginProxy.reloadFromControl(); + + assertEquals(BungeeMethod.HTTP, votingPluginProxy.getMethod()); + votingPluginProxy.setVoteCachePendingVotePartyReward("Server1", "delivery", false); + votingPluginProxy.reloadFromControl(); + assertEquals(BungeeMethod.MYSQL, votingPluginProxy.getMethod()); + } + + @Test + void transportReloadRetainsHttpForPendingServerAndOnlineDeliveries() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote serverVote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "uuid", + "Service", 100L, true, "totals"); + serverVote.setHttpDeliveryId("Server1", "00000000-0000-0000-0000-000000000180"); + OfflineBungeeVote onlineVote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "uuid", + "Service", 101L, true, "totals"); + onlineVote.setHttpBroadcastDeliveryId("Server2", "00000000-0000-0000-0000-000000000181"); + Mockito.when(voteCache.getCachedVotesServers()).thenReturn(new String[] { "Server1" }); + Mockito.when(voteCache.getVotes("Server1")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(serverVote))); + Mockito.when(voteCache.getOnlineVoteUUIDs()).thenReturn(java.util.Set.of("uuid")); + Mockito.when(voteCache.getOnlineVotes("uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(onlineVote))); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(spyProxy.getConfig().getBungeeMethod()).thenReturn("MYSQL"); + + spyProxy.reloadFromControl(); + + assertEquals(BungeeMethod.HTTP, spyProxy.getMethod()); + } + + @Test + void transportReloadRetainsHttpForPendingTimedBroadcast() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue timed = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L); + timed.setHttpBroadcastDeliveryId("Server1", "00000000-0000-0000-0000-000000000182"); + Mockito.when(voteCache.getTimeChangeQueue()) + .thenReturn(new java.util.concurrent.ConcurrentLinkedQueue<>(java.util.List.of(timed))); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(spyProxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + + spyProxy.reloadFromControl(); + + assertEquals(BungeeMethod.HTTP, spyProxy.getMethod()); + } + + @Test + void transportReloadRetainsHttpForOrdinaryDurableQueueEntry() throws Exception { + HttpProxyTransportServer transport = Mockito.mock(HttpProxyTransportServer.class); + setProxyField(votingPluginProxy, "httpTransportServer", transport); + votingPluginProxy.setPendingHttpTransportDeliveries(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("MQTT"); + + votingPluginProxy.reloadFromControl(); + + assertEquals(BungeeMethod.HTTP, votingPluginProxy.getMethod()); + assertTrue(votingPluginProxy.isRetainingHttpTransportForDeferredReconciliation(), + "a full platform reload must keep this runtime's live HTTP listener until its queue drains"); + assertTrue(votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()); + } + + @Test + void ordinaryFullReloadsDoNotProbeHttpRetention() { + votingPluginProxy.setMethod(BungeeMethod.REDIS); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("HTTP"); + assertFalse(votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()); + + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("HTTP"); + assertFalse(votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()); + } + + @Test + void changedHttpEndpointProbesRetentionButAnUnchangedListenerDoesNot() throws Exception { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + setProxyField(votingPluginProxy, "httpTransportServer", Mockito.mock(HttpProxyTransportServer.class)); + setProxyField(votingPluginProxy, "liveHttpHost", "127.0.0.1"); + setProxyField(votingPluginProxy, "liveHttpPort", 8080); + setProxyField(votingPluginProxy, "liveHttpPublicEndpoint", "https://old.example:8080"); + Mockito.when(votingPluginProxy.getConfig().getHttpHost()).thenReturn("127.0.0.1"); + Mockito.when(votingPluginProxy.getConfig().getHttpPort()).thenReturn(8080); + Mockito.when(votingPluginProxy.getConfig().getHttpPublicEndpoint()).thenReturn("https://old.example:8080"); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("HTTP"); + assertFalse(votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()); + + Mockito.when(votingPluginProxy.getConfig().getHttpPublicEndpoint()).thenReturn("https://new.example:8443"); + assertTrue(votingPluginProxy.requiresHttpRetentionCheckBeforeRuntimeReplacement()); + } + + @Test + void deferredHttpTransportChangeReconcilesAfterTheFinalAcknowledgement() throws Exception { + HttpProxyTransportServer transport = Mockito.mock(HttpProxyTransportServer.class); + setProxyField(votingPluginProxy, "httpTransportServer", transport); + java.util.concurrent.ScheduledExecutorService scheduler = java.util.concurrent.Executors + .newSingleThreadScheduledExecutor(); + try { + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setPendingHttpTransportDeliveries(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + + votingPluginProxy.reloadFromControl(); + assertEquals(BungeeMethod.HTTP, votingPluginProxy.getMethod()); + + votingPluginProxy.setPendingHttpTransportDeliveries(false); + votingPluginProxy.acknowledgeHttpDeliveryForTest("Server1", "delivery"); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2); + while (votingPluginProxy.getReloadCoreCalls() == 0 && System.nanoTime() < deadline) + Thread.sleep(10L); + + assertEquals(1, votingPluginProxy.getReloadCoreCalls(), + "the retained HTTP runtime must be rebuilt after its durable queue drains"); + } finally { + scheduler.shutdownNow(); + } + } + + @Test + void deferredHttpEndpointChangeReconcilesAfterTheFinalAcknowledgement() throws Exception { + HttpProxyTransportServer transport = Mockito.mock(HttpProxyTransportServer.class); + setProxyField(votingPluginProxy, "httpTransportServer", transport); + setProxyField(votingPluginProxy, "liveHttpHost", "old.example"); + setProxyField(votingPluginProxy, "liveHttpPort", 8080); + setProxyField(votingPluginProxy, "liveHttpPublicEndpoint", "https://old.example:8080"); + java.util.concurrent.ScheduledExecutorService scheduler = java.util.concurrent.Executors + .newSingleThreadScheduledExecutor(); + try { + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setPendingHttpTransportDeliveries(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("HTTP"); + Mockito.when(votingPluginProxy.getConfig().getHttpHost()).thenReturn("new.example"); + Mockito.when(votingPluginProxy.getConfig().getHttpPort()).thenReturn(8443); + Mockito.when(votingPluginProxy.getConfig().getHttpPublicEndpoint()) + .thenReturn("https://new.example:8443"); + + votingPluginProxy.reloadFromControl(); + assertEquals(BungeeMethod.HTTP, votingPluginProxy.getMethod()); + + votingPluginProxy.setPendingHttpTransportDeliveries(false); + votingPluginProxy.acknowledgeHttpDeliveryForTest("Server1", "delivery"); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(2); + while (votingPluginProxy.getReloadCoreCalls() == 0 && System.nanoTime() < deadline) + Thread.sleep(10L); + + assertEquals(1, votingPluginProxy.getReloadCoreCalls(), + "a changed HTTP endpoint must replace the retained runtime after its queue drains"); + } finally { + scheduler.shutdownNow(); + } + } - // Add one vote party - spyProxy.addCurrentVotePartyVotes(1); + @Test + void deferredHttpTransportChangePollsWhenAckPrecedesQueueRemoval() throws Exception { + HttpProxyTransportServer transport = Mockito.mock(HttpProxyTransportServer.class); + setProxyField(votingPluginProxy, "httpTransportServer", transport); + java.util.concurrent.ScheduledExecutorService scheduler = java.util.concurrent.Executors + .newSingleThreadScheduledExecutor(); + try { + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setPendingHttpTransportDeliveries(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + + votingPluginProxy.reloadFromControl(); + // The acknowledgement callback is intentionally observed before SimpleAPI + // removes its entry, so the first reconciliation still sees it as pending. + votingPluginProxy.acknowledgeHttpDeliveryForTest("Server1", "delivery"); + Thread.sleep(200L); + assertEquals(0, votingPluginProxy.getReloadCoreCalls()); + + votingPluginProxy.setPendingHttpTransportDeliveries(false); + long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(3); + while (votingPluginProxy.getReloadCoreCalls() == 0 && System.nanoTime() < deadline) + Thread.sleep(10L); + + assertEquals(1, votingPluginProxy.getReloadCoreCalls(), + "the deferred runtime must observe queue removal even without another acknowledgement"); + } finally { + scheduler.shutdownNow(); + } + } - // Verify that votePartyVotes increased by 1 - assertEquals(1, spyProxy.getVotePartyVotes()); + @Test + void concurrentManualReloadInvalidatesQueuedDeferredHttpReload() throws Exception { + HttpProxyTransportServer transport = Mockito.mock(HttpProxyTransportServer.class); + setProxyField(votingPluginProxy, "httpTransportServer", transport); + java.util.concurrent.ScheduledExecutorService scheduler = java.util.concurrent.Executors + .newSingleThreadScheduledExecutor(); + try { + votingPluginProxy.setSchedulerForTest(scheduler); + votingPluginProxy.setPendingHttpTransportDeliveries(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + votingPluginProxy.reloadFromControl(); + + // A direct platform reload changes the configured transport while the old + // acknowledgement task is queued. It must invalidate that task before it + // can enter the platform's reload coordination path. + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("HTTP"); + Thread manualReload = new Thread(votingPluginProxy::reloadFromControl); + manualReload.start(); + manualReload.join(java.util.concurrent.TimeUnit.SECONDS.toMillis(1)); + assertFalse(manualReload.isAlive()); + Thread.sleep(300L); + + assertEquals(0, votingPluginProxy.getReloadCoreCalls(), + "an invalidated deferred generation must not perform a stale full replacement"); + } finally { + scheduler.shutdownNow(); + } } @Test - void testAddCurrentVotePartyVotes() { - // Initial votePartyVotes should be 0 - assertEquals(0, votingPluginProxy.getVotePartyVotes()); + void startupRetainsHttpForPersistedQueueWithItsLastListenerSettings() throws Exception { + java.nio.file.Path backendQueue = temporaryDirectory.resolve("http/outgoing-v1/lobby-1"); + java.nio.file.Files.createDirectories(backendQueue); + java.nio.file.Files.writeString(backendQueue.resolve(".pending-delivery.json"), "pending"); + setProxyField(votingPluginProxy, "httpTransportServer", Mockito.mock(HttpProxyTransportServer.class)); + setProxyField(votingPluginProxy, "liveHttpHost", "127.0.0.1"); + setProxyField(votingPluginProxy, "liveHttpPort", 8080); + setProxyField(votingPluginProxy, "liveHttpPublicEndpoint", "https://old.example.test:8080"); + votingPluginProxy.setPendingHttpTransportDeliveries(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + Mockito.when(votingPluginProxy.getConfig().getBungeeMethod()).thenReturn("REDIS"); + votingPluginProxy.reloadFromControl(); + assertTrue(java.nio.file.Files.isRegularFile( + temporaryDirectory.resolve("http/retained-listener-v1.bin"))); + + VotingPluginProxyTestImpl restarted = new VotingPluginProxyTestImpl(); + restarted.setDataFolder(temporaryDirectory.toFile()); + restarted.setMethod(BungeeMethod.HTTP); + Mockito.when(restarted.getConfig().getBungeeMethod()).thenReturn("REDIS"); + Mockito.when(restarted.getConfig().getHttpHost()).thenReturn(""); + Mockito.when(restarted.getConfig().getHttpPort()).thenReturn(0); + Mockito.when(restarted.getConfig().getHttpPublicEndpoint()).thenReturn(""); + + restarted.reloadFromControl(); + + assertEquals(BungeeMethod.HTTP, restarted.getMethod()); + Object retained = getProxyField(restarted, "retainedHttpStartupSettings"); + assertEquals("127.0.0.1", getField(retained, "host")); + assertEquals(8080, getField(retained, "port")); + assertEquals("https://old.example.test:8080", getField(retained, "publicEndpoint")); + } - // Add 3 votes - votingPluginProxy.addCurrentVotePartyVotes(3); - assertEquals(3, votingPluginProxy.getVotePartyVotes()); + @Test + void httpVotePartyRetainsHungCommandFenceWhenQuarantineIsNotDurable() { + configureHttpVotePartyEffects("", java.util.List.of("hung command", "next command")); + java.util.concurrent.CompletableFuture completion = + votingPluginProxy.delayNextVotePartyCommandCompletion(); + votingPluginProxy.checkVoteParty(); + votingPluginProxy.failNextVoteCacheSave(); + + votingPluginProxy.runVotePartyProxyCommandTimeoutForTest(); + + assertEquals(java.util.List.of("hung command", "next command"), + votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().commands()); + assertTrue(votingPluginProxy.getVoteCacheQuarantinedVotePartyProxyEffects().isEmpty()); + assertFalse(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + assertEquals(java.util.List.of("hung command"), votingPluginProxy.getConsoleCommands()); + + completion.complete(null); + assertEquals(java.util.List.of("hung command", "next command"), votingPluginProxy.getConsoleCommands()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + } - // Add 2 more votes - votingPluginProxy.addCurrentVotePartyVotes(2); - assertEquals(5, votingPluginProxy.getVotePartyVotes()); + @Test + void committedHttpProxyEffectsRecoverAfterTransportModeChanges() { + votingPluginProxy.setMethod(BungeeMethod.SOCKETS); + votingPluginProxy.setVoteCachePendingVotePartyProxyEffects( + new com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects( + "Party time", java.util.List.of("reward all"))); + + assertTrue(votingPluginProxy.retryPendingVotePartyProxyEffectsForTest()); + + assertEquals(java.util.List.of("Party time"), votingPluginProxy.getBroadcasts()); + assertEquals(java.util.List.of("reward all"), votingPluginProxy.getConsoleCommands()); + assertTrue(votingPluginProxy.getVoteCachePendingVotePartyProxyEffects().isEmpty()); + } + + private void configureHttpVotePartyEffects(String broadcast, java.util.List commands) { + Mockito.when(votingPluginProxy.getConfig().getVotePartyEnabled()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getVotePartySendToAllServers()).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getVotePartyBroadcast()).thenReturn(broadcast); + Mockito.when(votingPluginProxy.getConfig().getVotePartyBungeeCommands()).thenReturn(commands); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVotePartyVotes(1); + votingPluginProxy.setCurrentVotePartyVotesRequired(1); } @Test @@ -107,7 +2267,7 @@ void invalidVoteStopsBeforeAnyPersistentRewardCacheOrForwardingState() { verify(spyProxy, never()).getVoteCacheHandler(); verify(spyProxy, never()).getGlobalMessageProxyHandler(); verify(proxyMySQL, never()).containsKeyQuery(Mockito.anyString()); - verify(multiProxyHandler, never()).sendMultiProxyEnvelope(Mockito.any()); + verify(multiProxyHandler, never()).sendMultiProxyEnvelopeAccepted(Mockito.any()); assertEquals(0, spyProxy.getVotePartyVotes(), username); assertTrue(spyProxy.getWarnings().stream().anyMatch(warning -> warning.contains("Rejected vote")), username); } @@ -350,6 +2510,76 @@ void pluginMessagingIgnoresExtendedPresenceLogin() { assertEquals(0, spyProxy.getBackendPlayerPresenceTracker().getOnlinePlayerCount()); } + @Test + void httpModernPresenceRejectsAuthenticatedBackendThatDoesNotMatchProxyRoute() { + String uuid = java.util.UUID.randomUUID().toString(); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(uuid).when(spyProxy).getUUID("Player"); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + spyProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = VotingPluginWire.login("Player", uuid, "Server2", java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), 1000L, 1100L); + + spyProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server2", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler, never()).onMessage(Mockito.any()); + assertEquals(0, spyProxy.getBackendPlayerPresenceTracker().getOnlinePlayerCount()); + } + + @Test + void httpModernPresenceAcceptsAuthenticatedBackendMatchingProxyRouteAndUuid() { + String uuid = java.util.UUID.randomUUID().toString(); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(uuid).when(spyProxy).getUUID("Player"); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + spyProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = VotingPluginWire.login("Player", uuid, "Server1", java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), 1000L, 1100L); + + spyProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server1", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler).onMessage(envelope); + } + + @Test + void httpModernPresenceRejectsUuidThatDoesNotMatchProxyPlayer() { + String authoritativeUuid = java.util.UUID.randomUUID().toString(); + String claimedUuid = java.util.UUID.randomUUID().toString(); + Mockito.when(votingPluginProxy.getConfig().getOnlineMode()).thenReturn(true); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(authoritativeUuid).when(spyProxy).getUUID("Player"); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + spyProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = VotingPluginWire.login("Player", claimedUuid, "Server1", java.util.UUID.randomUUID(), + java.util.UUID.randomUUID(), 1000L, 1100L); + + spyProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server1", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler, never()).onMessage(Mockito.any()); + assertEquals(0, spyProxy.getBackendPlayerPresenceTracker().getOnlinePlayerCount()); + } + + @Test + void httpEnvelopeRejectsServerFieldThatDoesNotMatchAuthenticatedBackend() { + votingPluginProxy.setMethod(BungeeMethod.HTTP); + var handler = Mockito.mock(com.bencodez.simpleapi.servercomm.global.GlobalMessageProxyHandler.class); + votingPluginProxy.setGlobalMessageProxyHandlerForTest(handler); + JsonEnvelope envelope = JsonEnvelope.builder("vote").put(VotingPluginWire.K_SERVER, "Server2").build(); + + votingPluginProxy.handleHttpTransportEnvelopeForTest(new com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer.ReceivedEnvelope( + "Server1", java.util.UUID.randomUUID().toString(), envelope)); + + verify(handler, never()).onMessage(Mockito.any()); + } + @Test void dedicatedVotingProxyRoutesUsingConfirmedBackendPresence() { Mockito.when(votingPluginProxy.getConfig().getDedicatedVotingProxy()).thenReturn(true); @@ -577,6 +2807,32 @@ void durablyProcessedRolloverVoteIsDeletedWithoutReplay() { verify(voteCache).removeTimeVote(processed); } + @Test + void processedRolloverVoteIsRetainedUntilStableHttpBroadcastDeliveryCompletes() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + String deliveryId = "00000000-0000-0000-0000-000000000167"; + VoteTimeQueue processed = new VoteTimeQueue(java.util.UUID.randomUUID(), "Player", "Service", 100L, true, + java.util.Set.of("Server1"), java.util.Collections.emptySet(), "totals", true, "player-uuid", + java.util.Map.of("Server1", deliveryId)); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(processed); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.updateTimeVote(processed)).thenReturn(true); + Mockito.when(voteCache.removeTimeVote(processed)).thenAnswer(invocation -> queue.remove(processed)); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.processQueue(); + + assertTrue(queue.isEmpty()); + assertFalse(processed.hasPendingHttpBroadcastDeliveryIds()); + verify(voteCache).updateTimeVote(processed); + verify(voteCache).removeTimeVote(processed); + verify(spyProxy, never()).getUUID(Mockito.anyString()); + } + @Test void pendingServerBroadcastRetriesBeforeOfflineRewardDelivery() { VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); @@ -604,6 +2860,103 @@ void pendingServerBroadcastRetriesBeforeOfflineRewardDelivery() { verify(voteCache).updateServerVote("Server1", vote); } + @Test + void acceptedCachedRewardRetainsAmbiguousStandaloneBroadcastWithoutRebroadcasting() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), false, java.util.Collections.emptyMap(), + java.util.Map.of("Server1", "00000000-0000-0000-0000-000000000170")); + Mockito.when(voteCache.hasVotes("Server1")).thenReturn(true); + Mockito.when(voteCache.getVotes("Server1")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(voteCache.updateServerVote("Server1", vote)).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + votingPluginProxy.setStableHttpDeliveryResults(false, true); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.checkCachedVotes("Server1"); + + assertTrue(vote.isRewardDelivered()); + assertFalse(vote.isProxyBroadcastComplete()); + assertEquals("false", spyProxy.getLastVoteEnvelope().getFields() + .get(VotingPluginWire.K_BUNGEE_BROADCAST)); + verify(voteCache).updateServerVote("Server1", vote); + verify(voteCache).removeServerVotes(Mockito.eq("Server1"), Mockito.argThat(java.util.List::isEmpty)); + } + + @Test + void rejectedHttpQueueDeliveryRetainsCachedServerVote() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals"); + Mockito.when(voteCache.hasVotes("Server1")).thenReturn(true); + Mockito.when(voteCache.getVotes("Server1")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()) + .thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.checkCachedVotes("Server1"); + + verify(voteCache).removeServerVotes(Mockito.eq("Server1"), + Mockito.argThat(java.util.List::isEmpty)); + } + + @Test + void rejectedHttpQueueDeliveryRetainsCachedOnlineVote() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals"); + Mockito.when(voteCache.hasOnlineVotes("player-uuid")).thenReturn(true); + Mockito.when(voteCache.getOnlineVotes("player-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()) + .thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.checkOnlineVotes("Player", "player-uuid", "Server1"); + + assertFalse(vote.isRewardDelivered()); + verify(voteCache, never()).removeOnlineVote("player-uuid", vote); + verify(multiProxyHandler, never()).sendClearVote(Mockito.anyString(), Mockito.anyString()); + } + + @Test + void acceptedOnlineRewardRetainsAmbiguousStandaloneBroadcastWithoutRebroadcasting() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "Player", "player-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), false, java.util.Collections.emptyMap(), + java.util.Map.of("Server1", "00000000-0000-0000-0000-000000000171")); + Mockito.when(voteCache.hasOnlineVotes("player-uuid")).thenReturn(true); + Mockito.when(voteCache.getOnlineVotes("player-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + votingPluginProxy.setStableHttpDeliveryResults(false, true); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.checkOnlineVotes("Player", "player-uuid", "Server1"); + + assertTrue(vote.isRewardDelivered()); + assertFalse(vote.isProxyBroadcastComplete()); + assertEquals("false", spyProxy.getLastVoteEnvelope().getFields() + .get(VotingPluginWire.K_BUNGEE_BROADCAST)); + verify(voteCache, never()).removeOnlineVote("player-uuid", vote); + } + @Test void pendingOnlineBroadcastRetriesWhenTargetGainsAnyCarrier() { VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); @@ -653,11 +3006,101 @@ void periodicRetryDeliversBrokerBackedOnlineCacheWithoutVoterLogin() { verify(voteCache).updateOnlineVote("voter-uuid", vote); } + @Test + void periodicRetryRemovesCompletedRewardJournalOwnerAfterTransientDeleteFailure() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote owner = new OfflineBungeeVote(java.util.UUID.randomUUID(), "OfflineVoter", "voter-uuid", + "Service", 100L, true, "totals", false, false, java.util.Collections.emptySet(), + java.util.Collections.emptySet(), true, + java.util.Map.of("__vp_reward_target__:Server1", + "00000000-0000-0000-0000-000000000172"), java.util.Collections.emptyMap()); + Mockito.when(voteCache.getOnlineVoteUUIDs()).thenReturn(java.util.Set.of("voter-uuid")); + Mockito.when(voteCache.getOnlineVotes("voter-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(owner))); + Mockito.when(voteCache.tryRemoveOnlineVote("voter-uuid", owner)).thenReturn(false, true); + votingPluginProxy.setSchedulerForTest(null); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.retryPendingOnlineBroadcasts(); + spyProxy.retryPendingOnlineBroadcasts(); + + verify(voteCache, Mockito.times(2)).tryRemoveOnlineVote("voter-uuid", owner); + verify(voteCache, never()).updateOnlineVote("voter-uuid", owner); + } + + @Test + void completedRewardJournalOwnerRetainsPendingStandaloneBroadcast() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote owner = new OfflineBungeeVote(java.util.UUID.randomUUID(), "OfflineVoter", "voter-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), true, + java.util.Map.of("__vp_reward_target__:Server1", + "00000000-0000-0000-0000-000000000173"), java.util.Collections.emptyMap()); + Mockito.when(voteCache.getOnlineVoteUUIDs()).thenReturn(java.util.Set.of("voter-uuid")); + Mockito.when(voteCache.getOnlineVotes("voter-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(owner))); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + + spyProxy.retryPendingOnlineBroadcasts(); + + verify(voteCache, never()).tryRemoveOnlineVote("voter-uuid", owner); + assertTrue(owner.needsBroadcastOn("Server1")); + } + + @Test + void rejectedPeriodicHttpBroadcastKeepsItsDeterministicIdentity() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "OfflineVoter", "voter-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), false); + Mockito.when(voteCache.getOnlineVoteUUIDs()).thenReturn(java.util.Set.of("voter-uuid")); + Mockito.when(voteCache.getOnlineVotes("voter-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(voteCache.updateOnlineVote("voter-uuid", vote)).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.retryPendingOnlineBroadcasts(); + + assertEquals(null, vote.getHttpBroadcastDeliveryId("Server1")); + verify(voteCache, never()).updateOnlineVote("voter-uuid", vote); + } + + @Test + void rejectedCarrierHttpBroadcastKeepsItsDeterministicIdentity() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + OfflineBungeeVote vote = new OfflineBungeeVote(java.util.UUID.randomUUID(), "OfflineVoter", "voter-uuid", + "Service", 100L, true, "totals", false, true, java.util.Set.of("Server1"), + java.util.Collections.emptySet(), false); + Mockito.when(voteCache.getOnlineVoteUUIDs()).thenReturn(java.util.Set.of("voter-uuid")); + Mockito.when(voteCache.getOnlineVotes("voter-uuid")) + .thenReturn(new java.util.ArrayList<>(java.util.List.of(vote))); + Mockito.when(voteCache.updateOnlineVote("voter-uuid", vote)).thenReturn(true); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(false); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.retryPendingOnlineBroadcastsForTest("Server1"); + + assertEquals(null, vote.getHttpBroadcastDeliveryId("Server1")); + verify(voteCache, never()).updateOnlineVote("voter-uuid", vote); + } + @Test void timedBroadcastRetriesWhileRolloverIsStillActive() { VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "OfflineVoter", "Service", 100L, true, - java.util.Set.of("Server1"), java.util.Collections.emptySet(), "totals", false, "voter-uuid"); + java.util.Set.of("Server1"), java.util.Collections.emptySet(), "totals", false, "voter-uuid", + java.util.Map.of("Server1", "stale-http-delivery-id")); java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); queue.add(vote); Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); @@ -669,9 +3112,120 @@ void timedBroadcastRetriesWhileRolloverIsStillActive() { spyProxy.retryPendingTimeBroadcastsForTest("Server1"); assertEquals(java.util.Set.of("Server1"), vote.getBroadcastForwardedServers()); + assertEquals(null, vote.getHttpBroadcastDeliveryId("Server1")); verify(voteCache).updateTimeVote(vote); } + @Test + void timedHttpBroadcastDoesNotSendUntilItsNewDeliveryIdIsPersisted() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "OfflineVoter", "Service", 100L, true, + java.util.Set.of("Server1"), java.util.Collections.emptySet(), "totals", false, "voter-uuid"); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(vote); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(voteCache.updateTimeVote(vote)).thenReturn(false); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + spyProxy.retryPendingTimeBroadcastsForTest("Server1"); + + assertTrue(vote.getHttpBroadcastDeliveryId("Server1") != null); + assertTrue(spyProxy.getAttemptedVotePartyDeliveryIds().isEmpty()); + } + + @Test + void timedHttpBroadcastPersistsIdBeforeSendAndReusesItAfterPostAcceptCrash() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "OfflineVoter", "Service", 100L, true, + java.util.Set.of("Server1"), java.util.Collections.emptySet(), "totals", false, "voter-uuid"); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(vote); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + java.util.concurrent.atomic.AtomicReference persistedDeliveryId = new java.util.concurrent.atomic.AtomicReference<>(); + Mockito.when(voteCache.updateTimeVote(vote)).thenAnswer(invocation -> { + if (persistedDeliveryId.get() == null) { + assertTrue(spyProxy.getAttemptedVotePartyDeliveryIds().isEmpty()); + persistedDeliveryId.set(vote.getHttpBroadcastDeliveryId("Server1")); + return true; + } + throw new IllegalStateException("simulated crash after HTTP acceptance"); + }); + + assertThrows(IllegalStateException.class, () -> spyProxy.retryPendingTimeBroadcastsForTest("Server1")); + String deliveryId = persistedDeliveryId.get(); + assertTrue(deliveryId != null && !deliveryId.isBlank()); + assertEquals(java.util.List.of(deliveryId), spyProxy.getAttemptedVotePartyDeliveryIds()); + + VoteTimeQueue restored = new VoteTimeQueue(vote.getVoteId(), vote.getName(), vote.getService(), vote.getTime(), + vote.isProxyBroadcastHandled(), vote.getBroadcastTargets(), java.util.Collections.emptySet(), + vote.getTotals(), vote.isProcessed(), vote.getUuid(), + java.util.Map.of("Server1", deliveryId)); + java.util.Queue restoredQueue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + restoredQueue.add(restored); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(restoredQueue); + Mockito.when(voteCache.updateTimeVote(restored)).thenReturn(true); + spyProxy.setVoteEnvelopeDeliveryResult(true); + spyProxy.retryPendingTimeBroadcastsForTest("Server1"); + + assertTrue(restored.getBroadcastForwardedServers().contains("Server1")); + assertEquals(null, restored.getHttpBroadcastDeliveryId("Server1")); + assertEquals(java.util.List.of(deliveryId, deliveryId), votingPluginProxy.getAttemptedVotePartyDeliveryIds()); + } + + @Test + void timedHttpBroadcastPersistsEachAcceptedTargetBeforePreparingTheNext() { + VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); + VoteTimeQueue vote = new VoteTimeQueue(java.util.UUID.randomUUID(), "OfflineVoter", "Service", 100L, true, + new java.util.LinkedHashSet<>(java.util.List.of("Server1", "Server2")), + java.util.Collections.emptySet(), "totals", false, "voter-uuid"); + java.util.Queue queue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + queue.add(vote); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(queue); + Mockito.when(votingPluginProxy.getConfig().getBlockedServers()).thenReturn(java.util.Collections.emptyList()); + votingPluginProxy.setMethod(BungeeMethod.HTTP); + votingPluginProxy.setVoteEnvelopeDeliveryResult(true); + + VotingPluginProxyTestImpl spyProxy = Mockito.spy(votingPluginProxy); + Mockito.doReturn(voteCache).when(spyProxy).getVoteCacheHandler(); + java.util.concurrent.atomic.AtomicReference persistedBeforeSecondSend = + new java.util.concurrent.atomic.AtomicReference<>(); + Mockito.when(voteCache.updateTimeVote(vote)).thenAnswer(invocation -> { + if (vote.getHttpBroadcastDeliveryId("Server2") != null) { + persistedBeforeSecondSend.set(new VoteTimeQueue(vote.getVoteId(), vote.getName(), vote.getService(), + vote.getTime(), vote.isProxyBroadcastHandled(), vote.getBroadcastTargets(), + vote.getBroadcastForwardedServers(), vote.getTotals(), vote.isProcessed(), vote.getUuid(), + vote.getHttpBroadcastDeliveryIds())); + throw new IllegalStateException("simulated crash after persisting the second target ID"); + } + return true; + }); + + assertThrows(IllegalStateException.class, spyProxy::retryPendingTimeBroadcastsForTest); + VoteTimeQueue restored = persistedBeforeSecondSend.get(); + assertTrue(restored.getBroadcastForwardedServers().contains("Server1")); + assertFalse(restored.getBroadcastForwardedServers().contains("Server2")); + String secondDeliveryId = restored.getHttpBroadcastDeliveryId("Server2"); + assertTrue(secondDeliveryId != null && !secondDeliveryId.isBlank()); + + java.util.Queue restoredQueue = new java.util.concurrent.ConcurrentLinkedQueue<>(); + restoredQueue.add(restored); + Mockito.when(voteCache.getTimeChangeQueue()).thenReturn(restoredQueue); + Mockito.when(voteCache.updateTimeVote(restored)).thenReturn(true); + spyProxy.retryPendingTimeBroadcastsForTest(); + + assertEquals(2, votingPluginProxy.getAttemptedVotePartyDeliveryIds().size()); + assertEquals(secondDeliveryId, votingPluginProxy.getAttemptedVotePartyDeliveryIds().get(1)); + } + @Test void failedTimedDeliveryStatePersistenceRemainsDirtyUntilRetrySucceeds() { VoteCacheHandler voteCache = Mockito.mock(VoteCacheHandler.class); @@ -737,4 +3291,22 @@ void completedBroadcastOnlyOnlineVoteIsRemovedAfterRetry() { verify(voteCache).removeOnlineVote("voter-uuid", vote); verify(voteCache, never()).updateOnlineVote("voter-uuid", vote); } + + private static void setProxyField(VotingPluginProxy target, String name, Object value) throws Exception { + java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField(name); + field.setAccessible(true); + field.set(target, value); + } + + private static Object getProxyField(VotingPluginProxy target, String name) throws Exception { + java.lang.reflect.Field field = VotingPluginProxy.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } + + private static Object getField(Object target, String name) throws Exception { + java.lang.reflect.Field field = target.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(target); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java index ecdd7e6d1..0f7e362ad 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginProxyTestImpl.java @@ -4,6 +4,8 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashSet; +import java.util.HashMap; +import java.util.Collection; import java.util.List; import java.util.Set; import java.util.UUID; @@ -17,21 +19,58 @@ import com.bencodez.votingplugin.proxy.OfflineBungeeVote; import com.bencodez.votingplugin.proxy.VotingPluginProxy; import com.bencodez.votingplugin.proxy.VotingPluginProxyConfig; +import com.bencodez.votingplugin.proxy.cache.PendingVotePartyProxyEffects; +import com.bencodez.simpleapi.servercomm.http.HttpProxyTransportServer; import com.bencodez.votingplugin.timequeue.VoteTimeQueue; public class VotingPluginProxyTestImpl extends VotingPluginProxy { private final List warnings = new ArrayList<>(); private VotingPluginProxyConfig config; private boolean pluginMessageDeliveryResult = true; + private boolean voteEnvelopeDeliveryResult = true; + private Boolean stableHttpDeliveryResult; + private final java.util.ArrayDeque stableHttpDeliveryResults = new java.util.ArrayDeque<>(); + private JsonEnvelope lastVoteEnvelope; private boolean communicationTestDeliveryResult = true; private JsonEnvelope lastCommunicationTestEnvelope; private boolean playerOnline = true; + private Set availableServers = new HashSet<>(Arrays.asList("Server1", "Server2")); + private Boolean standaloneBroadcastForwarding; private ScheduledExecutorService scheduler; + private boolean failNextVoteCacheSave; + private final java.util.Map> pendingVotePartyRewards = new HashMap<>(); + private final List attemptedVotePartyDeliveryIds = new ArrayList<>(); + private boolean failNextGeneratedHttpSend; + private boolean failNextStableHttpSend; + private String generatedHttpRetryId; + private PendingVotePartyProxyEffects pendingVotePartyProxyEffects = PendingVotePartyProxyEffects.empty(); + private PendingVotePartyProxyEffects quarantinedVotePartyProxyEffects = PendingVotePartyProxyEffects.empty(); + private final List broadcasts = new ArrayList<>(); + private final List consoleCommands = new ArrayList<>(); + private boolean failNextBroadcast; + private String failConsoleCommand; + private boolean failSaveAfterNextBroadcast; + private java.util.concurrent.CompletableFuture nextVotePartyCommandCompletion; + private boolean declineNextVotePartyCommand; + private Runnable votePartyProxyCommandTimeout; + private Boolean pendingHttpTransportDeliveries; + private volatile int reloadCoreCalls; + private File dataFolder = new File("."); public List getWarnings() { return warnings; } + public void setPendingHttpTransportDeliveries(Boolean pending) { + pendingHttpTransportDeliveries = pending; + } + + @Override + protected boolean httpTransportHasPendingDeliveries(HttpProxyTransportServer transport) { + return pendingHttpTransportDeliveries != null ? pendingHttpTransportDeliveries + : super.httpTransportHasPendingDeliveries(transport); + } + @Override public void addNonVotedPlayer(String uuid, String playerName) { // Implementation for testing purposes @@ -39,12 +78,34 @@ public void addNonVotedPlayer(String uuid, String playerName) { @Override public void broadcast(String message) { - // Implementation for testing purposes + if (failNextBroadcast) { + failNextBroadcast = false; + throw new IllegalStateException("broadcast failed"); + } + broadcasts.add(message); + if (failSaveAfterNextBroadcast) { + failSaveAfterNextBroadcast = false; + failNextVoteCacheSave = true; + } } @Override public Set getAllAvailableServers() { - return new HashSet<>(Arrays.asList("Server1", "Server2")); + return new HashSet<>(availableServers); + } + + public void setAvailableServers(String... servers) { + availableServers = new HashSet<>(Arrays.asList(servers)); + } + + public void setStandaloneBroadcastForwarding(Boolean enabled) { + standaloneBroadcastForwarding = enabled; + } + + @Override + protected boolean canForwardStandaloneBroadcast(boolean managesTotals) { + return standaloneBroadcastForwarding == null + ? super.canForwardStandaloneBroadcast(managesTotals) : standaloneBroadcastForwarding.booleanValue(); } @Override @@ -70,7 +131,11 @@ public String getCurrentPlayerServer(String player) { @Override public File getDataFolderPlugin() { - return new File("."); + return dataFolder; + } + + public void setDataFolder(File dataFolder) { + this.dataFolder = dataFolder; } @Override @@ -123,6 +188,27 @@ public int getVoteCacheVotePartyIncreaseVotesRequired() { return 10; } + @Override + public Collection getVoteCachePendingVotePartyServers() { + return new HashSet<>(pendingVotePartyRewards.keySet()); + } + + @Override + public Collection getVoteCachePendingVotePartyRewardIds(String server) { + return new HashSet<>(pendingVotePartyRewards.getOrDefault(server.toLowerCase(java.util.Locale.ROOT), + java.util.Set.of())); + } + + @Override + public PendingVotePartyProxyEffects getVoteCachePendingVotePartyProxyEffects() { + return pendingVotePartyProxyEffects; + } + + @Override + public PendingVotePartyProxyEffects getVoteCacheQuarantinedVotePartyProxyEffects() { + return quarantinedVotePartyProxyEffects; + } + @Override public boolean isPlayerOnline(String playerName) { return playerOnline; @@ -154,17 +240,95 @@ public void runAsync(Runnable run) { @Override public void runConsoleCommand(String command) { - // Mocked for testing + if (command.equals(failConsoleCommand)) { + failConsoleCommand = null; + throw new IllegalStateException("command failed"); + } + consoleCommands.add(command); + } + + @Override + protected java.util.concurrent.CompletableFuture runVotePartyConsoleCommand(String command) { + java.util.concurrent.CompletableFuture completion = nextVotePartyCommandCompletion; + nextVotePartyCommandCompletion = null; + if (declineNextVotePartyCommand) { + declineNextVotePartyCommand = false; + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("proxy declined the vote-party command")); + } + runConsoleCommand(command); + return completion == null ? java.util.concurrent.CompletableFuture.completedFuture(null) : completion; + } + + @Override + protected void scheduleVotePartyProxyCommandTimeout(Runnable timeout) { + votePartyProxyCommandTimeout = timeout; + } + + @Override + protected void awaitInFlightVotePartyProxyCommand() { + // Keep shutdown-path tests deterministic; production performs the bounded wait. } @Override public void saveVoteCacheFile() { - // Mocked for testing + if (failNextVoteCacheSave) { + failNextVoteCacheSave = false; + throw new IllegalStateException("vote cache save failed"); + } + } + + @Override + public void saveVotePartyStateDurably() { + saveVoteCacheFile(); + } + + public void failNextVoteCacheSave() { + failNextVoteCacheSave = true; + } + + public void failNextBroadcast() { + failNextBroadcast = true; + } + + public void failConsoleCommand(String command) { + failConsoleCommand = command; + } + + public void declineNextVotePartyCommand() { + declineNextVotePartyCommand = true; + } + + public void failSaveAfterNextBroadcast() { + failSaveAfterNextBroadcast = true; + } + + public List getBroadcasts() { + return List.copyOf(broadcasts); + } + + public List getConsoleCommands() { + return List.copyOf(consoleCommands); + } + + public java.util.concurrent.CompletableFuture delayNextVotePartyCommandCompletion() { + nextVotePartyCommandCompletion = new java.util.concurrent.CompletableFuture<>(); + return nextVotePartyCommandCompletion; + } + + public void runVotePartyProxyCommandTimeoutForTest() { + Runnable timeout = votePartyProxyCommandTimeout; + votePartyProxyCommandTimeout = null; + if (timeout != null) timeout.run(); } @Override public void reloadCore(boolean mysql) { - // Mocked for testing + reloadCoreCalls++; + } + + public int getReloadCoreCalls() { + return reloadCoreCalls; } @Override @@ -212,6 +376,118 @@ public boolean sendProxyBroadcastImmediately(String server, JsonEnvelope envelop return sendProxyBroadcastEnvelopeNow(server, envelope); } + @Override + protected boolean sendVoteEnvelopeAccepted(String server, int delay, JsonEnvelope envelope) { + lastVoteEnvelope = envelope; + if (getMethod() == com.bencodez.votingplugin.proxy.BungeeMethod.HTTP) { + return voteEnvelopeDeliveryResult; + } + return super.sendVoteEnvelopeAccepted(server, delay, envelope); + } + + public void setVoteEnvelopeDeliveryResult(boolean voteEnvelopeDeliveryResult) { + this.voteEnvelopeDeliveryResult = voteEnvelopeDeliveryResult; + } + + @Override + protected boolean sendHttpEnvelope(String server, JsonEnvelope envelope) { + lastVoteEnvelope = envelope; + if (failNextGeneratedHttpSend) { + failNextGeneratedHttpSend = false; + try { + java.lang.reflect.Constructor constructor = + HttpProxyTransportServer.DeliveryRetryException.class + .getDeclaredConstructor(String.class, Throwable.class); + constructor.setAccessible(true); + throw constructor.newInstance(generatedHttpRetryId, new IllegalStateException("publication ambiguous")); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } + return voteEnvelopeDeliveryResult; + } + + @Override + protected boolean sendHttpEnvelope(String server, String deliveryId, JsonEnvelope envelope) { + lastVoteEnvelope = envelope; + attemptedVotePartyDeliveryIds.add(deliveryId); + if (failNextStableHttpSend) { + failNextStableHttpSend = false; + throw deliveryRetryException(generatedHttpRetryId); + } + if (!stableHttpDeliveryResults.isEmpty()) return stableHttpDeliveryResults.removeFirst(); + return stableHttpDeliveryResult == null ? voteEnvelopeDeliveryResult : stableHttpDeliveryResult; + } + + public void setStableHttpDeliveryResult(Boolean stableHttpDeliveryResult) { + this.stableHttpDeliveryResult = stableHttpDeliveryResult; + } + + public void setStableHttpDeliveryResults(Boolean... results) { + stableHttpDeliveryResults.clear(); + java.util.Collections.addAll(stableHttpDeliveryResults, results); + } + + public JsonEnvelope getLastVoteEnvelope() { + return lastVoteEnvelope; + } + + public List getAttemptedVotePartyDeliveryIds() { + return attemptedVotePartyDeliveryIds; + } + + public void failNextGeneratedHttpSend(String deliveryId) { + failNextGeneratedHttpSend = true; + generatedHttpRetryId = deliveryId; + } + + public void failNextStableHttpSend(String deliveryId) { + failNextStableHttpSend = true; + generatedHttpRetryId = deliveryId; + } + + private static RuntimeException deliveryRetryException(String deliveryId) { + try { + java.lang.reflect.Constructor constructor = + HttpProxyTransportServer.DeliveryRetryException.class + .getDeclaredConstructor(String.class, Throwable.class); + constructor.setAccessible(true); + return constructor.newInstance(deliveryId, new IllegalStateException("publication ambiguous")); + } catch (ReflectiveOperationException failure) { + throw new AssertionError(failure); + } + } + + public boolean sendHttpVoteEnvelopeWithRecoveryForTest(String server, JsonEnvelope envelope) { + return sendHttpEnvelopeWithRecovery(server, envelope); + } + + public boolean sendGenericHttpEnvelopeForTest(String server, JsonEnvelope envelope) { + return sendGenericHttpEnvelope(server, envelope); + } + + public boolean sendStableHttpEnvelopeForTest(String server, String deliveryId, JsonEnvelope envelope) { + return sendStableHttpEnvelope(server, deliveryId, envelope); + } + + public boolean sendHttpVoteEnvelopeWithRecoveryForTest(String server, JsonEnvelope envelope, + OfflineBungeeVote cachedVote) { + return sendHttpEnvelopeWithRecovery(server, envelope, cachedVote); + } + + public boolean sendHttpBroadcastEnvelopeWithRecoveryForTest(String server, JsonEnvelope envelope, + OfflineBungeeVote cachedVote) { + return sendHttpBroadcastEnvelopeWithRecovery(server, envelope, cachedVote); + } + + public void acknowledgeVotePartyDeliveryForTest(String server, String deliveryId) throws java.io.IOException { + acknowledgeVotePartyDelivery(server, deliveryId); + } + + public void acknowledgeHttpDeliveryForTest(String server, String deliveryId) throws java.io.IOException { + acknowledgeHttpDelivery(server, deliveryId); + } + @Override protected boolean sendCommunicationTestEnvelopeNow(String server, JsonEnvelope envelope) { lastCommunicationTestEnvelope = envelope; @@ -230,6 +506,10 @@ public void handleLoginMessageForTest(JsonEnvelope envelope) { handleLoginMessage(envelope); } + public void handleHttpTransportEnvelopeForTest(HttpProxyTransportServer.ReceivedEnvelope received) { + handleHttpTransportEnvelope(received); + } + public void handleStatusOkayForTest(JsonEnvelope envelope) { handleStatusOkay(envelope); } @@ -264,10 +544,41 @@ public void retryPendingOnlineBroadcastsForTest(String server) { retryPendingOnlineBroadcasts(server); } + public boolean persistUncachedStandaloneBroadcastForTest(String uuid, OfflineBungeeVote state, + boolean alreadyPersisted) { + return persistUncachedStandaloneBroadcast(uuid, state, alreadyPersisted); + } + + public boolean persistAndSendStandaloneBroadcastForTest(String uuid, OfflineBungeeVote state, + java.util.Set remainingTargets, java.util.Set forwardedServers) { + return persistAndSendStandaloneBroadcast(uuid, state, remainingTargets, forwardedServers); + } + + public OfflineBungeeVote createCachedRewardVoteForTest(UUID voteId, String player, String uuid, String service, + long time, boolean realVote, String text, boolean standaloneProxyBroadcast) { + return createCachedRewardVote(voteId, player, uuid, service, time, realVote, text, standaloneProxyBroadcast); + } + public void retryPendingTimeBroadcastsForTest(String server) { retryPendingTimeBroadcasts(server); } + public void retryPendingTimeBroadcastsForTest() { + retryPendingTimeBroadcasts(); + } + + public void retryPendingVotePartyRewardsForTest() { + retryPendingVotePartyRewards(); + } + + public boolean retryPendingVotePartyProxyEffectsForTest() { + return retryPendingVotePartyProxyEffects(); + } + + public boolean quarantineInFlightVotePartyProxyCommandForReplacementForTest() { + return quarantineInFlightVotePartyProxyCommandForReplacement(); + } + public int[] getProjectedVotePartyStateForTest(int acceptedVotes) { return getProjectedVotePartyState(acceptedVotes); } @@ -346,6 +657,26 @@ public void setVoteCacheVotePartyIncreaseVotesRequired(int votes) { } + @Override + public void setVoteCachePendingVotePartyReward(String server, String deliveryId, boolean pending) { + server = server.toLowerCase(java.util.Locale.ROOT); + if (pending) pendingVotePartyRewards.computeIfAbsent(server, ignored -> new HashSet<>()).add(deliveryId); + else { + java.util.Set rewards = pendingVotePartyRewards.get(server); + if (rewards != null && rewards.remove(deliveryId) && rewards.isEmpty()) pendingVotePartyRewards.remove(server); + } + } + + @Override + public void setVoteCachePendingVotePartyProxyEffects(PendingVotePartyProxyEffects effects) { + pendingVotePartyProxyEffects = effects; + } + + @Override + public void setVoteCacheQuarantinedVotePartyProxyEffects(PendingVotePartyProxyEffects effects) { + quarantinedVotePartyProxyEffects = effects; + } + @Override public ScheduledExecutorService getScheduler() { return scheduler; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java index 66bcd22df..b01650df1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/backendproxy/cache/ProcessedVoteCacheTest.java @@ -82,6 +82,7 @@ public void legacyRedisHandoffAccountingIsBounded() throws Exception { Field deliveries = ProcessedVoteCache.class.getDeclaredField("legacyRedisDeliveries"); deliveries.setAccessible(true); assertEquals(4096, ((Map) deliveries.get(cache)).size()); + assertTrue(cache.isLegacyRedisHandoffOverflowed()); } @Test @@ -97,5 +98,6 @@ public void legacyRedisHandoffAccountingRejectsOversizedSignatures() throws Exce Field deliveries = ProcessedVoteCache.class.getDeclaredField("legacyRedisDeliveries"); deliveries.setAccessible(true); assertTrue(((Map) deliveries.get(cache)).isEmpty()); + assertTrue(cache.isLegacyRedisHandoffOverflowed()); } } diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 60468f37c..b80e34f5d 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -1,16 +1,39 @@ # VotingPlugin Control agent contract This is the compact source of truth for an AI agent or Control client implementing the Bukkit integration. The connector -has two separate lanes: +has three separate lanes: - configuration operations use the negotiated `config.files.v1` / `config.quick-setup.v1` contracts; Vote Party proposals that include `enabled` additionally require `config.quick-setup.v2`, and configuration operations may write only managed VotingPlugin YAML after preview and approval; - inspections use the optional `data.inspect.v1` contract and are always read-only. +- plugin deployment uses the optional `plugin.deploy.v1` contract and only stages a verified JAR for an explicit + administrator-selected restart; it never reloads or restarts the process. Do not translate an inspection request into a configuration operation. Do not add raw SQL, arbitrary commands, player enumeration, database browsing, filesystem paths, or generic key/value reads to either contract. +## Verified plugin deployment (`plugin.deploy.v1`) + +The capability is advertised only when the node can identify a safe staging target. Control assigns work through +`POST /api/v1/nodes/{nodeId}/deployments` with the current `sessionId`. A task contains `deploymentId`, `artifactId`, +lowercase `sha256`, byte `size`, and a leased `attemptId`. The node downloads only that assignment from +`GET /api/v1/nodes/{nodeId}/deployments/{deploymentId}/artifact`, authenticating with its enrolled credential and the +exact `X-Node-Session` and `X-Deployment-Attempt` headers. + +Downloads are limited to 64 MiB, do not follow redirects, require the assigned length and SHA-256, and must be a bounded +JAR with exactly one root `plugin.yml` declaring `VotingPlugin`. Staging rejects unsafe ZIP entries, symlinked targets, +oversized expansion, and compression bombs. Bukkit nodes atomically publish `VotingPlugin.jar` to the server update +folder. Proxy nodes first refresh a durable `.control-backup` from the current verified JAR, then atomically replace the +same discovered plugin JAR. Both write a small idempotency marker only after activation; a marker is trusted only after +rehashing and reinspecting the target. + +The node reports the result to `POST /api/v1/nodes/{nodeId}/deployments/{deploymentId}/result` with its `sessionId` and +the leased `attemptId`. Success is `RESTART_REQUIRED`; it means the JAR was staged, not loaded. Failures distinguish +download, size/hash, invalid artifact, staging/write, cancellation, and lost capability. Transfer and disk work run on a +dedicated background worker. Disable/reload marks the worker inactive, interrupts it, and prevents a stale completion +from submitting success. Mixed-version nodes that do not negotiate `plugin.deploy.v1` remain connected but are excluded +from deployment targets. ## Named reward files (`config.reward-files.v1`) This additive Bukkit capability manages only existing `Rewards/.yml` files directly under VotingPlugin's Rewards @@ -143,6 +166,65 @@ redacted-editor task; turning `useMainMySQL` off alone does not invent credentia unknown fields. `READ` returns current typed state and its revision, `PREVIEW` computes changes and produces an approval, and only the approved `APPLY` writes atomically, reloads, and rolls back on reload failure, like every other quick setup. +## Verified plugin staging (`plugin.deploy.v1`) + +This optional capability stages one verified VotingPlugin JAR for the node's next process restart. It is never a hot reload +and never restarts a proxy or backend automatically. A node advertises it only when all of the following are true: + +- the connector is the currently enabled Control route, not a recovery-only connector draining an older durable result; +- a safe local staging target was prepared; +- the Control endpoint is HTTPS, or it is the already-proven direct same-node hosted HTTP listener. + +Arbitrary private-network HTTP does not qualify for deployment because the artifact request carries the node bearer +credential. The shared staging service enforces the same transport rule again before sending that credential. + +Control leases deployment work separately from configuration operations: + +```http +POST /api/v1/nodes/{nodeId}/deployments +Content-Type: application/json + +{"sessionId":""} +``` + +A `204` means no work. A `200` task contains `deploymentId`, `artifactId`, lowercase SHA-256 `sha256`, byte +`size`, and `attemptId`. Artifact size is limited to 64 MiB. The node downloads the exact leased artifact with: + +```http +GET /api/v1/nodes/{nodeId}/deployments/{deploymentId}/artifact +Authorization: Bearer +X-Node-Session: +X-Deployment-Attempt: +``` + +The node independently verifies the exact size and SHA-256, bounded ZIP/JAR structure, root `plugin.yml`, and +`name: VotingPlugin` before publication. Bukkit nodes stage to the server update folder; proxy nodes atomically replace +their discovered plugin JAR only after creating a durable `.control-backup`. A small durable +`.control-deployment` marker is published before the verified target is moved into place, so a lost result +acknowledgement does not apply the same artifact twice. On Bukkit, a consumed staged update must match the installed JAR. +On proxies, both a safe backup and a target JAR matching the marker's digest are required for acknowledgement; a missing +marker requires a fresh verified download and staging attempt. + +The result is posted to: + +```http +POST /api/v1/nodes/{nodeId}/deployments/{deploymentId}/result +Content-Type: application/json + +{ + "sessionId":"", + "attemptId":"", + "success":true, + "code":"RESTART_REQUIRED", + "message":"Plugin update staged; restart is required" +} +``` + +Failure codes include `CANCELLED`, `DOWNLOAD_FAILED`, `SIZE_MISMATCH`, `HASH_MISMATCH`, +`INVALID_ARTIFACT`, `STAGING_FAILED`, `DEPLOYMENT_FAILED`, and `INSECURE_ENDPOINT`. Deployment polling is +serialized with the connector lifecycle, uses the normal bounded failure backoff, and is cancelled during shutdown before +transport teardown. + ## Inspection transport An enrolled Bukkit node advertises `data.inspect.v1`. Once Control includes it in `acceptedCapabilities`, the node polls: diff --git a/docs/control-connector.md b/docs/control-connector.md index ae40c78b3..711843d8a 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -201,8 +201,40 @@ with the new settings. This prevents the old and new children from racing for th the currently running Control child unchanged. If the backend restarts with a pending result, the durable journal also restores the previous hosted settings until that result is acknowledged, preserving the recovery connector's endpoint. +## Verified VotingPlugin updates + +Nodes that negotiate `plugin.deploy.v1` can stage a VotingPlugin JAR supplied by Control for the next normal process +restart. This capability is deliberately separate from configuration control and from hosted-Control self-updates. +VotingPlugin never hot-reloads itself and never restarts the server or proxy automatically. + +Deployment is available only on the currently enabled Control route. Recovery-only connectors that exist solely to +acknowledge an older durable result never advertise or poll this capability. The node also requires a credential-safe +artifact transport: HTTPS is accepted generally; HTTP is accepted only when the existing hosted-Control checks prove the +endpoint is the direct same-node listener. A LAN/private HTTP endpoint may still be used for ordinary Control operations, +but it is intentionally ineligible for credentialed plugin-JAR staging. + +Control leases deployment work through `POST /api/v1/nodes/{nodeId}/deployments`. The node downloads the artifact through +the matching deployment artifact endpoint with its bearer credential plus exact session and attempt headers, then +independently verifies the 64 MiB size bound, SHA-256, JAR structure, and root `plugin.yml` identity. Bukkit stages the +verified JAR in the configured update folder; BungeeCord/Velocity retain a durable backup before atomically replacing the +running plugin JAR on disk. A durable deployment marker makes lost result acknowledgements idempotent, including the +post-restart Bukkit state where the server has already consumed the staged update JAR. + +Successful staging reports `RESTART_REQUIRED`. The administrator chooses when to restart. See +[the Control agent contract](control-agent-contract.md#verified-plugin-staging-plugindeployv1) for the exact task, download, +result, transport, and failure-code contract. + ## Discovery semantics +When Control and a node both negotiate `plugin.deploy.v1`, the authenticated WebUI may explicitly select and upload a +VotingPlugin JAR for staging on that capable node. The connector downloads only its leased assignment, verifies its +declared size, SHA-256, bounded JAR structure, and root `plugin.yml`, and performs disk work on a dedicated background +worker. Bukkit stages into the configured update folder; BungeeCord and Velocity refresh a local backup and atomically +replace the currently discovered plugin JAR. A successful result is `RESTART_REQUIRED`: Control never automatically +reloads or restarts a server. Older nodes simply omit the capability and remain usable for their negotiated features. +See [the Control agent contract](control-agent-contract.md#verified-plugin-deployment-plugindeployv1) for the exact lease, +download, result, and safety contract. + Both platforms use the same connector implementation and protocol version `1`. Each proxy process creates a new session ID, registers, and sends full replacement backend snapshots with a monotonically increasing sequence. Registration and retries are idempotent. A Control restart is detected by a 404 heartbeat/presence response and causes re-registration. diff --git a/docs/http-transport.md b/docs/http-transport.md new file mode 100644 index 000000000..544bc1507 --- /dev/null +++ b/docs/http-transport.md @@ -0,0 +1,54 @@ +# Secure HTTP proxy transport + +The `HTTP` bungee method gives every backend an outbound encrypted connection to one HTTPS listener on the proxy. Only the proxy port is opened; backend servers need no inbound transport ports. + +## Quick setup + +1. On the proxy, set the following in `bungeeconfig.yml`: + + ```yaml + BungeeMethod: HTTP + HTTP: + Host: '0.0.0.0' + Port: 1297 + PublicEndpoint: 'https://proxy.example.com:1297/' + ``` + +2. Allow TCP port `1297` to the proxy. `PublicEndpoint` must resolve directly to this VotingPlugin listener. +3. Restart the proxy and run `/votingpluginproxy httpcode `. The name must exactly identify the intended backend; generate a separate code for each backend. +4. On a backend, set a unique `Server`, enable bungee mode, select `HTTP`, and paste its code into `BungeeSettings.yml`: + + ```yaml + UseBungeecord: true + Server: lobby-1 + BungeeMethod: HTTP + HTTP: + ConnectionCode: 'paste-code-here' + ``` + +5. Restart the backend. Once enrollment succeeds, remove `ConnectionCode` from the configuration. The backend's private identity is stored in its VotingPlugin data folder and is reused automatically. A different nonblank code is treated as an explicit re-enrollment request; a digest lets harmless restarts recognize the already-consumed code without storing its secret token. + +Connection codes expire after 15 minutes and can be used only once. Treat a fresh code like a temporary password: transfer it privately and do not publish it in logs, tickets, or chat rooms. + +## Security model + +- TLS keys and a private certificate authority are generated automatically on the proxy. No shared transport password or public CA setup is required. +- Enrollment pins both the exact proxy certificate and its private authority. Normal traffic trusts only that pinned private authority and keeps HTTPS hostname verification enabled, allowing the proxy leaf certificate to rotate safely without trusting public certificate authorities. It uses a distinct client certificate bound to that backend's canonical `Server` name. +- Proxy and backend leaf certificates renew automatically during a 30-day pre-expiry window. Backend renewal is authenticated by the still-valid mTLS identity, persisted before use, and switches the proxy binding only after the replacement successfully connects; no new connection code is needed. +- Every normal request is authenticated again at the application boundary. A payload cannot claim another backend identity, and redirect following is disabled. +- TLS 1.3 is required and weak protocols are disabled. +- Enrollment tokens are 256-bit random capabilities, single-use, short-lived, and retained by the proxy only as hashes. +- Credentials, keys, pins, and revocation state are written atomically with owner-only permissions where the operating system supports them. +- Request bodies, queues, batches, worker pools, concurrent requests, per-backend polling, and request rates are bounded. Exact paths, methods, and JSON content types are enforced. +- Proxy-to-backend messages stay in an owner-only durable proxy queue until the backend acknowledgement is durably applied. The backend fsyncs three delivery states around each callback: a reserved callback resumes after restart, a completed callback is acknowledged without running twice, and a running callback with an uncertain outcome is neither replayed nor acknowledged. The proxy therefore retains the durable source copy instead of silently losing it. The completed entry is deleted only after a matching authenticated HTTP 200 confirms the proxy removed the queued message. +- A host failure while a callback is running has an inherently ambiguous result because arbitrary reward commands and external plugin effects cannot share a transaction with the transport journal. Such a delivery remains quarantined for operator investigation, avoiding both an automatic duplicate reward and an acknowledgement that could hide a missed reward. +- HTTP vote-party threshold commits journal the exact proxy broadcast and ordered proxy commands alongside backend reward deliveries. Pending proxy effects resume after restart and advance only after execution completes and progress is fsynced. These local effects are at-least-once: a crash after an effect executes but before its progress record is durable can repeat that effect rather than silently skip it. A Velocity command that does not complete within 60 seconds is moved to the durable `VoteParty.QuarantinedProxyEffects` list without automatic retry so it cannot block later vote-party work or execute concurrently with a retry; operators must inspect and resolve quarantined commands. +- Backend-to-proxy messages retain the existing bounded in-process retry semantics. Vote caching remains responsible for application-level durability in that direction. + +The listener must terminate TLS itself because client-certificate authentication is part of the protocol. Do not put an HTTP TLS-terminating reverse proxy or CDN in front of it. A TCP/L4 proxy that passes TLS through unchanged is suitable. Internet-facing installations should also use the host firewall or provider firewall for volumetric denial-of-service protection; an application cannot fully absorb a link or TCP flood. + +If a backend host or its private credential is compromised, run `/votingpluginproxy httprevoke ` on the proxy before generating a new connection code. Revocation takes effect on the next request and permits a replacement identity to enroll under that server name. Keep the proxy's `http` data directory backed up and private: it contains the transport authority. + +## Performance + +The connector reuses HTTP/1.1 TLS connections, batches messages and acknowledgements, and performs all network, certificate, and delivery-journal work off the game thread. A two-second bounded long poll avoids busy polling while limiting the worst-case delay for a backend message queued just after an idle request began. Callback lanes are single-worker FIFO queues; when their fixed capacity is reached, bounded listener/poller workers apply backpressure instead of dropping an older callback and admitting newer work out of order. All queues and admission pools remain bounded to prevent traffic bursts from causing unbounded memory growth.