diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 53c3165608..24a46a79df 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -339,6 +339,9 @@ public String formatVoteCommandMessage(String message) { private volatile VotingPluginBackgroundTask backgroundTask; private volatile AtomicBoolean basicBungeeUpdateRunning; + private final AtomicBoolean unloadInProgress = new AtomicBoolean(); + private Set completedUnloadSteps; + private java.util.concurrent.CompletionStage signShutdownPublication; private synchronized AtomicBoolean basicBungeeUpdateAdmission() { if (basicBungeeUpdateRunning == null) basicBungeeUpdateRunning = new AtomicBoolean(); @@ -794,6 +797,7 @@ private void metrics() { @Override public void onPostLoad() { + serverData.refreshVotePartyDisplay(); ensureCommunicationSecret(); // auto conversion for Shop.yml if (plugin.getShopFile().isJustCreated()) { @@ -2011,9 +2015,95 @@ private void migrateVoteBroadcast(Config configFile) { configFile.saveData(); } + @Getter + private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence serverDataPublicationFence; + + private volatile com.bencodez.votingplugin.data.GenerationFencedServerDataFile fencedServerDataFile; + + private java.util.concurrent.CompletableFuture serverDataFinalPublication; + private boolean serverDataFinalPublicationReserved; + private boolean serverDataFinalStageAttached; + private volatile boolean serverDataEnableCleanupStarted; + private volatile boolean serverDataEnableAdmissionRejected; + + @Override + public com.bencodez.advancedcore.data.ServerData getServerDataFile() { + var file = super.getServerDataFile(); + var current = fencedServerDataFile; + if (current != null && current.wraps(file)) { + // The SDK replaces its file after pre-load. A failed new generation has + // no file to finalize; never expose the predecessor as its current owner. + return current.belongsTo(serverDataPublicationFence) ? current : null; + } + synchronized (this) { + file = super.getServerDataFile(); + var owner = serverDataPublicationFence; + if (file == null || owner == null) return file; + current = fencedServerDataFile; + if (current != null && current.wraps(file) && !current.belongsTo(owner)) return null; + if (current == null || !current.wraps(file)) { + current = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(this, file, owner); + fencedServerDataFile = current; + } + return current; + } + } + + /** Capture the existing AdvancedCore executors which can still publish server-data state. */ + public ScheduledExecutorService[] getServerDataWriterExecutors() { + var checker = getTimeChecker(); + return new ScheduledExecutorService[] { getTimer(), getLoginTimer(), getInventoryTimer(), + checker == null ? null : checker.getTimer() }; + } + + @Override + public void onEnable() { + synchronized (this) { + // Bind a retained SDK file before changing owner. AdvancedCore replaces its + // private file only later in loadHook(), after onPreLoad(). + if (serverDataPublicationFence != null) getServerDataFile(); + serverDataPublicationFence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration( + getDataFolder().toPath().resolve("ServerData.yml")); + try { serverDataPublicationFence.retainForProcessExit(); } + catch (RuntimeException | Error registrationFailure) { + serverDataEnableAdmissionRejected = true; + try { serverDataPublicationFence.retire(); } + catch (RuntimeException | Error cleanupFailure) { + if (cleanupFailure != registrationFailure) registrationFailure.addSuppressed(cleanupFailure); + } + throw registrationFailure; + } + serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>(); + serverDataFinalPublicationReserved = false; + serverDataFinalStageAttached = false; + serverDataEnableCleanupStarted = false; + serverDataEnableAdmissionRejected = false; + } + // Failed startup can already own SDK writers; disable must retire them before replacement. + try { super.onEnable(); } + catch (RuntimeException | Error failure) { + // SDK admission can fail before its startup-cleanup try/catch. Such an + // attempt admitted no new writers and must not strand an empty plugin owner. + // An admitted startup failure already entered our onDisable; preserve its drain. + if (!serverDataEnableCleanupStarted) { + serverDataEnableAdmissionRejected = true; + try { + serverDataPublicationFence.retire(); + serverDataFinalPublication.complete(null); + } catch (RuntimeException | Error cleanupFailure) { + if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); + } + } + throw failure; + } + } + @Override public void onPreLoad() { plugin = this; + unloadInProgress.set(false); + completedUnloadSteps = new java.util.HashSet<>(); + signShutdownPublication = null; setupFiles(); if ("SQLITE".equalsIgnoreCase(configFile.getData().getString("DataStorage", "SQLITE"))) { @@ -2055,16 +2145,68 @@ public void onFinish() { @Override public void onDisable() { - if (discordHandler != null) discordHandler.close(); - stopAutoCreateVoteSitesSetupReminder(); - if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close(); + // The rejected enable owns no SDK shutdown stage to complete a new reservation. + if (serverDataEnableAdmissionRejected) return; + runShutdownStep("server-data process drain", this::beginServerDataProcessDrain); + serverDataEnableCleanupStarted = true; + runShutdownStep("Discord leaderboards", () -> { + if (discordHandler != null) discordHandler.close(); + }); + runShutdownStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder); + runShutdownStep("server-data writer retirement", () -> { + if (serverDataPublicationFence != null) { + retainServerDataFinalPublication(); + var coreWriters = getServerDataWriterExecutors(); + var writers = java.util.Arrays.copyOf(coreWriters, coreWriters.length + 1); + writers[coreWriters.length] = voteTimer; + serverDataPublicationFence.retainUntilTermination(writers); + } + }); try { - shutdownVoteReminders(); + runShutdownStep("offline vote handoffs", () -> { + if (offlineVoteOwnerHandoffs != null) offlineVoteOwnerHandoffs.close(); + }); } finally { - super.onDisable(); + try { shutdownVoteReminders(); } + finally { + try { super.onDisable(); } + finally { runShutdownStep("server-data publication owner", () -> { + if (serverDataPublicationFence != null) serverDataPublicationFence.retire(); + }); } + } } } + /** Reserve before SDK shutdown can enqueue a final file publication. No YAML lock or wait. */ + private void retainServerDataFinalPublication() { + synchronized (this) { + if (serverDataFinalPublicationReserved) return; + if (serverDataFinalPublication == null) + serverDataFinalPublication = new java.util.concurrent.CompletableFuture<>(); + serverDataPublicationFence.retainUntilSuccessfulPublication(serverDataFinalPublication); + serverDataFinalPublicationReserved = true; + } + } + + @Override + public void onShutdownComplete(java.util.concurrent.CompletionStage finalFilesStage) { + java.util.concurrent.CompletableFuture publication; + synchronized (this) { + publication = serverDataFinalStageAttached ? null : serverDataFinalPublication; + serverDataFinalStageAttached = true; + } + try { + if (publication != null) { + try { + java.util.Objects.requireNonNull(finalFilesStage).whenComplete((ignored, failure) -> { + if (failure == null) publication.complete(null); + else publication.completeExceptionally(failure); + }); + } catch (Throwable failure) { publication.completeExceptionally(failure); } + } + } finally { super.onShutdownComplete(finalFilesStage); } + } + private Settings autoCreateVoteSitesReminderSettings() { return new Settings(true, configFile.isAutoCreateVoteSites(), AutoCreateVoteSitesReminderState.DEFAULT_THRESHOLD_MILLIS, @@ -2092,52 +2234,107 @@ private void shutdownVoteReminders() { @Override public void onUnLoad() { - if (discordHandler != null) discordHandler.close(); - retireTopVoterDisplayFilter(); - if (voteStreaksMenu != null) voteStreaksMenu.close(); - stopAutoCreateVoteSitesSetupReminder(); - placeholderPlayerPresence.clear(); - stopBackendHostedControlLifecycle(); - stopBackendControlConnectorLifecycle(); - if (getBackendProxyHandler() != null) { - try { - getBackendProxyHandler().closeForShutdown(); - } catch (Exception e) { - debug(e); - } - } - if (backendOrderedVoteOverflowQueue != null) { - backendOrderedVoteOverflowQueue.close(); - backendOrderedVoteOverflowQueue = null; - } - if (webhookManager != null) { - webhookManager.shutdown(); + runShutdownStep("server-data process drain", this::beginServerDataProcessDrain); + if (!unloadInProgress.compareAndSet(false, true)) { + runShutdownStep("time queue retry", () -> { + if (timeQueueHandler != null) timeQueueHandler.saveForShutdown(); + }); + return; } - voteTimer.shutdown(); try { - voteTimer.awaitTermination(1, TimeUnit.SECONDS); - } catch (InterruptedException e) { - e.printStackTrace(); - } - voteTimer.shutdownNow(); - if (votifierVoteOverflowQueue != null) { - votifierVoteOverflowQueue.close(); - votifierVoteOverflowQueue = null; - } - if (timeQueueHandler != null) { - timeQueueHandler.save(); - } + if (completedUnloadSteps == null) completedUnloadSteps = new java.util.HashSet<>(); + runUnloadStep("Discord leaderboards", () -> { + if (discordHandler != null) discordHandler.close(); + }); + runUnloadStep("top voter display", this::retireTopVoterDisplayFilter); + runUnloadStep("streak menu", () -> { + if (voteStreaksMenu != null) voteStreaksMenu.close(); + }); + runUnloadStep("setup reminder", this::stopAutoCreateVoteSitesSetupReminder); + runUnloadStep("player presence", placeholderPlayerPresence::clear); + runUnloadStep("backend hosted Control", this::stopBackendHostedControlLifecycle); + runUnloadStep("backend Control connector", this::stopBackendControlConnectorLifecycle); + runUnloadStep("backend proxy", () -> { + if (getBackendProxyHandler() != null) getBackendProxyHandler().closeForShutdown(); + }); + runUnloadStep("backend ordered vote queue", () -> { + if (backendOrderedVoteOverflowQueue != null) { + backendOrderedVoteOverflowQueue.close(); + backendOrderedVoteOverflowQueue = null; + } + }); + runUnloadStep("webhook manager", () -> { + if (webhookManager != null) webhookManager.shutdown(); + }); + runUnloadStep("vote timer", this::shutdownVoteTimer); + runUnloadStep("Votifier vote queue", () -> { + if (votifierVoteOverflowQueue != null) { + votifierVoteOverflowQueue.close(); + votifierVoteOverflowQueue = null; + } + }); + runShutdownStep("time queue", () -> { + if (timeQueueHandler != null) timeQueueHandler.saveForShutdown(); + }); + runUnloadStep("vote reminders", this::shutdownVoteReminders); + runUnloadStep("cooldown checker", () -> { + if (coolDownCheck != null) { + coolDownCheck.shutdown(); + coolDownCheck = null; + } + }); + runShutdownStep("signs", this::shutdownSigns); + runUnloadStep("event handlers", () -> HandlerList.unregisterAll(this)); + // Late producers must see rejection until their overflow and handler cleanup finishes. + if (completedUnloadSteps.contains("vote timer")) voteTimer = null; + if (plugin == this) plugin = null; + } finally { unloadInProgress.set(false); } + } + + private void beginServerDataProcessDrain() { + var owner = serverDataPublicationFence; + if (owner != null) owner.beginShutdown(System.nanoTime()); + } - shutdownVoteReminders(); + private void shutdownSigns() { + if (signs == null) return; + var previous = signShutdownPublication; + if (previous != null && !previous.toCompletableFuture().isCompletedExceptionally()) return; + signShutdownPublication = java.util.Objects.requireNonNull(signs.storeSignsForShutdown()); + } + + private void runUnloadStep(String name, Runnable step) { + if (!completedUnloadSteps.contains(name) && runShutdownStep(name, step)) completedUnloadSteps.add(name); + } - if (coolDownCheck != null) { - coolDownCheck.shutdown(); - coolDownCheck = null; + private boolean runShutdownStep(String name, Runnable step) { + try { + step.run(); + return true; + } catch (RuntimeException | Error failure) { + try { + getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down " + name, failure); + } catch (RuntimeException | Error ignored) { + // Logging must not prevent the next independent cleanup step. + } + return false; } + } - getSigns().storeSigns(); - HandlerList.unregisterAll(plugin); - plugin = null; + private void shutdownVoteTimer() { + if (voteTimer == null) return; + try { + voteTimer.shutdown(); + if (!voteTimer.awaitTermination(1, TimeUnit.SECONDS)) voteTimer.shutdownNow(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + voteTimer.shutdownNow(); + } catch (RuntimeException failure) { + try { + getLogger().log(java.util.logging.Level.WARNING, "Failed to shut down vote timer", failure); + } catch (RuntimeException | Error ignored) { } + voteTimer.shutdownNow(); + } } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/GenerationFencedServerDataFile.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/GenerationFencedServerDataFile.java new file mode 100644 index 0000000000..cd635d4471 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/GenerationFencedServerDataFile.java @@ -0,0 +1,247 @@ +package com.bencodez.votingplugin.data; + +import java.io.File; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.plugin.Plugin; + +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +/** + * Routes SDK publications through this generation's ownership lease. The SDK's + * original file/configuration remains authoritative; no YAML snapshot is copied. + */ +public final class GenerationFencedServerDataFile extends com.bencodez.advancedcore.data.ServerData { + private final com.bencodez.advancedcore.data.ServerData delegate; + private final ServerDataPublicationFence fence; + private final VotingPluginMain ownerPlugin; + + public GenerationFencedServerDataFile(VotingPluginMain plugin, + com.bencodez.advancedcore.data.ServerData delegate, ServerDataPublicationFence fence) { + super(plugin); + this.ownerPlugin = java.util.Objects.requireNonNull(plugin); + this.delegate = java.util.Objects.requireNonNull(delegate); + this.fence = java.util.Objects.requireNonNull(fence); + } + + public boolean wraps(com.bencodez.advancedcore.data.ServerData file) { return delegate == file; } + + public boolean belongsTo(ServerDataPublicationFence owner) { return fence == owner; } + + /** Checkpoint callers use the same monitor as native final publication. */ + @Override public Object publicationMonitor() { + return java.util.Objects.requireNonNull(delegate.publicationMonitor(), "Server-data publication monitor"); + } + + private void withOwner(Runnable operation) { + try (var lease = fence.acquire()) { + synchronized (publicationMonitor()) { + try { operation.run(); } + catch (RuntimeException | Error failure) { + try { refreshVotePartyDisplay(); } + catch (RuntimeException | Error observationFailure) { + if (observationFailure != failure) failure.addSuppressed(observationFailure); + } + throw failure; + } + refreshVotePartyDisplay(); + } + } + } + + private T withOwnedValue(Supplier operation) { + try (var lease = fence.acquire()) { + synchronized (publicationMonitor()) { return operation.get(); } + } + } + + @Override public FileConfiguration getData() { return delegate.getData(); } + @Override public File getdFile() { return delegate.getdFile(); } + @Override public JavaPlugin getPlugin() { return delegate.getPlugin(); } + @Override public boolean isJustCreated() { return delegate.isJustCreated(); } + @Override public boolean isFailedToRead() { return delegate.isFailedToRead(); } + @Override public boolean isIgnoreCase() { return delegate.isIgnoreCase(); } + @Override public void setIgnoreCase(boolean value) { + withOwner(() -> delegate.setIgnoreCase(value)); + } + @Override public void createSection(String path) { + mutateOrdinaryData(data -> data.createSection(path)); + } + @Override public void setData(FileConfiguration data) { + withOwner(() -> delegate.setData(data)); + } + @Override public void setValue(String path, Object value) { + mutateOrdinaryData(data -> data.set(path, value)); + } + @Override public void onFileCreation() { + withOwner(delegate::onFileCreation); + } + + @Override public void setup() { + withOwner(delegate::setup); + } + @Override public void loadValues() { + withOwner(delegate::loadValues); + } + @Override public void reloadData() { + withOwner(delegate::reloadData); + } + private void refreshVotePartyDisplay() { + var wrapper = ownerPlugin.getServerData(); + if (wrapper != null) { + synchronized (publicationMonitor()) { + if (delegate.getData() != null) wrapper.refreshVotePartyDisplay(); + } + } + } + + @Override public void saveData() { + withOwner(() -> { + refreshVotePartyDisplay(); + delegate.saveData(); + }); + } + @Override public void saveDataChecked() { + withOwner(() -> { + refreshVotePartyDisplay(); + delegate.saveDataChecked(); + }); + } + @Override protected void saveTimeChangeTransitionData() { saveDataChecked(); } + + @Override public void mutateOrdinaryData(Consumer mutation) { + // Delegate admission, live mutation and saving to the actual SDK owner. + // The projection is published inside that owner's callback before I/O. + withOwner(() -> delegate.mutateOrdinaryData(data -> applyOwnedMutation(mutation, data))); + } + + private void refreshIfLiveMutation(FileConfiguration data) { + var wrapper = ownerPlugin.getServerData(); + if (wrapper == null) return; + synchronized (publicationMonitor()) { + // Native final publication mutates a staged candidate first. Legacy + // adapters may instead mutate the live owner before their save. + if (data == delegate.getData()) wrapper.refreshVotePartyDisplay(); + } + } + + private void applyOwnedMutation(Consumer mutation, FileConfiguration data) { + try { mutation.accept(data); } + catch (RuntimeException | Error failure) { + try { refreshIfLiveMutation(data); } + catch (RuntimeException | Error observationFailure) { + if (observationFailure != failure) failure.addSuppressed(observationFailure); + } + throw failure; + } + refreshIfLiveMutation(data); + } + + private void finishFinalPublication(ServerDataPublicationFence.Lease lease, + CompletableFuture result, Throwable failure) { + Throwable outcome = failure; + try { + // Partial startup may have no display wrapper. In that case, do not + // introduce a YAML-observation dependency into final-stage drain. + refreshVotePartyDisplay(); + } catch (Throwable observationFailure) { + if (outcome == null) outcome = observationFailure; + else if (outcome != observationFailure) outcome.addSuppressed(observationFailure); + } + // Refresh observes only the actual live owner; it never acknowledges a + // failed/cancelled publication or releases its retained generation. + if (outcome != null) { result.completeExceptionally(outcome); return; } + try { lease.close(); result.complete(null); } + catch (Throwable releaseFailure) { result.completeExceptionally(releaseFailure); } + } + + /** Retain this handle only for retries of the same accepted mutation. */ + FinalPublication prepareFinalPublication(Consumer mutation) { + return new FinalPublication(mutation); + } + + final class FinalPublication { + private final Consumer mutation; + private ServerDataPublicationFence.Lease lease; + private CompletableFuture attempt; + private boolean publicationConfirmed; + + private FinalPublication(Consumer mutation) { this.mutation = mutation; } + + CompletionStage publish() { + CompletableFuture result; + ServerDataPublicationFence.Lease owner; + boolean refreshOnly; + synchronized (this) { + if (attempt != null && !attempt.isCompletedExceptionally()) + return attempt.minimalCompletionStage(); + if (lease == null) { + try { lease = fence.acquire(); } + catch (Throwable failure) { return CompletableFuture.failedFuture(failure); } + } + owner = lease; + result = new CompletableFuture<>(); + attempt = result; + refreshOnly = publicationConfirmed; + } + // No operation lock is held across the actual owner's YAML or I/O. + if (refreshOnly) finish(owner, result, null); + else { + try { + java.util.Objects.requireNonNull(mutation, "mutation"); + java.util.Objects.requireNonNull(delegate.mutateFinalData(data -> applyOwnedMutation(mutation, data))) + .whenComplete((ignored, failure) -> finish(owner, result, failure)); + } catch (Throwable failure) { finish(owner, result, failure); } + } + return result.minimalCompletionStage(); + } + + private void finish(ServerDataPublicationFence.Lease owner, + CompletableFuture result, Throwable failure) { + if (failure == null) { + synchronized (this) { publicationConfirmed = true; } + } + // A retry after a display-observation failure must not repeat a + // confirmed mutation. Failed publication instead retains this one + // lease until this same operation has a confirmed successful retry. + finishFinalPublication(owner, result, failure); + } + } + + @Override public CompletionStage mutateFinalData(Consumer mutation) { + // Ordinary one-shot callers retain failed ownership conservatively. + // A caller with an explicit retry contract retains the operation handle. + return prepareFinalPublication(mutation).publish(); + } + + @Override public void setData(String path, Object value) { mutateOrdinaryData(data -> data.set(path, value)); } + @Override public void setIgnoreTime(boolean value) { withOwner(() -> delegate.setIgnoreTime(value)); } + @Override public void setLastUpdated() { withOwner(delegate::setLastUpdated); } + @Override public void setPluginVersion(Plugin plugin) { withOwner(() -> delegate.setPluginVersion(plugin)); } + @Override public void setPrevDay(int value) { withOwner(() -> delegate.setPrevDay(value)); } + @Override public void setPrevMonth(String value) { withOwner(() -> delegate.setPrevMonth(value)); } + @Override public void setPrevWeekDay(int value) { withOwner(() -> delegate.setPrevWeekDay(value)); } + + @Override public TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) { + return withOwnedValue(() -> delegate.beginTimeChangeTransition(type, period, marker)); + } + @Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) { + return withOwnedValue(() -> delegate.getPendingTimeChangeTransition(type)); + } + @Override public void completeTimeChangeTransition(TimeChangeTransitionState transition) { + withOwner(() -> delegate.completeTimeChangeTransition(transition)); + } + @Override public void failTimeChangeTransition(TimeChangeTransitionState transition) { + withOwner(() -> delegate.failTimeChangeTransition(transition)); + } + @Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) { + return withOwnedValue(() -> delegate.isPendingTimeChangeTransition(transition)); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java index 3b13816343..fe02a32c31 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/data/ServerData.java @@ -18,9 +18,182 @@ import com.bencodez.votingplugin.timequeue.VoteTimeQueue; import com.bencodez.votingplugin.topvoter.TopVoter; -public class ServerData { +public class ServerData { + + /** Immutable live display values, separate from durable publication acknowledgement. */ + public record VotePartyDisplay(int total, int extraRequired) { + public int required(int configured) { return extraRequired > 0 ? configured + extraRequired : configured; } + public int needed(int configured) { return required(configured) - total; } + } + + private volatile VotePartyDisplay votePartyDisplay = new VotePartyDisplay(0, 0); + + /** Snapshot-only: never acquires a YAML owner or waits for filesystem publication. */ + public VotePartyDisplay getVotePartyDisplay() { return votePartyDisplay; } + + /** Called during startup before placeholders are registered. */ + public void refreshVotePartyDisplay() { mutateData(this::captureVotePartyDisplay); } + + private void captureVotePartyDisplay() { + var data = getFile().getData().getConfigurationSection("VotingPlugin"); + votePartyDisplay = data == null ? new VotePartyDisplay(0, 0) + : new VotePartyDisplay(data.getInt("VoteParty.Total"), data.getInt("VotePartyExtraRequired")); + } private VotingPluginMain plugin = VotingPluginMain.plugin; + private volatile com.bencodez.advancedcore.data.ServerData generationFile; + private final Object generationBinding = new Object(); + private volatile com.bencodez.votingplugin.util.ServerDataPublicationFence generationFence; + + private com.bencodez.advancedcore.data.ServerData getFile() { + var current = generationFile; + if (current != null) return current; + synchronized (generationBinding) { + if (generationFile == null) { + var owner = getPublicationFence(); + // Reject an old lazy wrapper before exposing successor mutable state. + try (var lease = owner == null ? null : owner.acquire()) { + var file = plugin.getServerDataFile(); + if (file instanceof GenerationFencedServerDataFile guarded && !guarded.belongsTo(owner)) + throw new IllegalStateException("Server-data file belongs to another generation"); + generationFile = file; + } + } + return generationFile; + } + } + + + + private record SignCoordinates(String world, int x, int y, int z) { + static SignCoordinates capture(Location location) { + return new SignCoordinates(location.getWorld().getName(), (int) location.getX(), + (int) location.getY(), (int) location.getZ()); + } + void store(ConfigurationSection data, String path) { + data.set(path + ".World", world); + data.set(path + ".X", x); + data.set(path + ".Y", y); + data.set(path + ".Z", z); + } + } + + + /** Detached shutdown payload: no platform object is accessed by its publisher. */ + public static final class SignSnapshot { + private final String count; + private final SignCoordinates location; + private final SignCoordinates skull; + private final String data; + private final int position; + + private SignSnapshot(String count, Location location, Location skull, String data, int position) { + this.count = count; + this.location = SignCoordinates.capture(location); + this.skull = skull == null ? null : SignCoordinates.capture(skull); + this.data = data; + this.position = position; + } + + private void store(ConfigurationSection target) { + location.store(target, "Signs." + count); + target.set("Signs." + count + ".Data", data); + target.set("Signs." + count + ".Position", position); + if (skull != null) skull.store(target, "Signs." + count + ".Skull"); + } + } + + public static SignSnapshot captureSignSnapshot(String count, Location location, Location skull, + String data, int position) { + return new SignSnapshot(count, location, skull, data, position); + } + + private record ShutdownSignPublication(List snapshot, + java.util.function.Supplier> publisher) { } + private final Object shutdownSignBinding = new Object(); + private ShutdownSignPublication shutdownSignPublication; + + /** Worker-only: one accepted sign batch, with the same publication owner across retries. */ + public java.util.concurrent.CompletionStage publishShutdownSigns(List signs) { + var snapshot = List.copyOf(signs); + ShutdownSignPublication publication; + synchronized (shutdownSignBinding) { + if (shutdownSignPublication == null) { + var file = getFile(); + java.util.function.Consumer mutation = candidate -> { + var target = candidate.getConfigurationSection("VotingPlugin"); + if (target == null) target = candidate.createSection("VotingPlugin"); + for (var sign : snapshot) sign.store(target); + }; + java.util.function.Supplier> publisher; + if (file instanceof GenerationFencedServerDataFile guarded) { + var retained = guarded.prepareFinalPublication(mutation); + publisher = retained::publish; + } else publisher = () -> file.mutateFinalData(mutation); + shutdownSignPublication = new ShutdownSignPublication(snapshot, publisher); + } else if (!shutdownSignPublication.snapshot().equals(snapshot)) { + return java.util.concurrent.CompletableFuture.failedFuture( + new IllegalStateException("Shutdown sign publication already owns a different batch")); + } + publication = shutdownSignPublication; + } + return publication.publisher().get(); + } + + /** Only YAML work belongs here; platform callbacks run outside the file owner. */ + private void mutateData(Runnable mutation) { + var fence = getPublicationFence(); + try (var owner = fence == null ? null : fence.acquire()) { + synchronized (publicationMonitor()) { + mutation.run(); + } + } + } + + /** Admit the complete ordinary mutation before changing live YAML or its display. */ + private void mutateSavedData(Runnable mutation) { + mutateData(() -> getFile().mutateOrdinaryData(ignored -> { + try { mutation.run(); } + catch (RuntimeException | Error failure) { + try { captureVotePartyDisplay(); } + catch (RuntimeException | Error observationFailure) { + if (observationFailure != failure) failure.addSuppressed(observationFailure); + } + throw failure; + } + captureVotePartyDisplay(); + })); + } + + /** Store a scalar and save under the same YAML ownership as final SDK snapshots. */ + public void setInt(String path, int value) { + mutateSavedData(() -> { + getData().set(path, value); + }); + } + + /** Store a detached string list, preventing later caller mutation of live YAML. */ + public void setStringList(String path, List values) { + var snapshot = values == null ? null : new ArrayList<>(values); + mutateSavedData(() -> { + getData().set(path, snapshot); + }); + } + + /** All mutations serialize with the actual SDK YAML owner. */ + private Object publicationMonitor() { + var file = getFile(); + return java.util.Objects.requireNonNull(file.publicationMonitor(), "Server-data publication monitor"); + } + + private com.bencodez.votingplugin.util.ServerDataPublicationFence getPublicationFence() { + var current = generationFence; + if (current != null) return current; + synchronized (generationBinding) { + if (generationFence == null) generationFence = plugin.getServerDataPublicationFence(); + return generationFence; + } + } /** * Constructs a new ServerData. @@ -29,6 +202,7 @@ public class ServerData { */ public ServerData(VotingPluginMain plugin) { this.plugin = plugin; + this.generationFence = plugin.getServerDataPublicationFence(); } /** @@ -36,13 +210,15 @@ public ServerData(VotingPluginMain plugin) { * * @param placeholder the placeholder to add */ - public void addAutoCachedPlaceholder(String placeholder) { - List p = getAutoCachedPlaceholder(); - - if (!ArrayUtils.containsIgnoreCase(p, placeholder)) { - p.add(placeholder); - setAutoCachedPlaceholder(p); - } + public void addAutoCachedPlaceholder(String placeholder) { + mutateData(() -> { + List p = getAutoCachedPlaceholder(); + + if (!ArrayUtils.containsIgnoreCase(p, placeholder)) { + p.add(placeholder); + setAutoCachedPlaceholder(p); + } + }); } /** @@ -50,12 +226,14 @@ public void addAutoCachedPlaceholder(String placeholder) { * * @param site the service site to add */ - public synchronized void addServiceSite(String site) { - ArrayList l = getServiceSites(); - if (!getServiceSites().contains(site)) { - l.add(site); - } - setServiceSites(ArrayUtils.removeDuplicates(l)); + public void addServiceSite(String site) { + mutateData(() -> { + ArrayList l = getServiceSites(); + if (!getServiceSites().contains(site)) { + l.add(site); + } + setServiceSites(ArrayUtils.removeDuplicates(l)); + }); } /** @@ -65,19 +243,20 @@ public synchronized void addServiceSite(String site) { * @param data the data * @param position the position */ - public void addSign(Location location, String data, int position) { - - int count = nextSignNumber(); - - getData().set("Signs." + count + ".World", location.getWorld().getName()); - getData().set("Signs." + count + ".X", location.getBlockX()); - getData().set("Signs." + count + ".Y", location.getBlockY()); - getData().set("Signs." + count + ".Z", location.getBlockZ()); - getData().set("Signs." + count + ".Data", data); - getData().set("Signs." + count + ".Position", position); - saveData(); - plugin.getSigns().getSigns().add(new SignHandler(plugin, "" + count, getSignLocation("" + count), - getSignSkullLocation("" + count), getSignData("" + count), getSignPosition("" + count))); + public void addSign(Location location, String data, int position) { + var coordinates = new SignCoordinates(location.getWorld().getName(), location.getBlockX(), + location.getBlockY(), location.getBlockZ()); + int[] selected = new int[1]; + mutateSavedData(() -> { + int count = nextSignNumber(); + selected[0] = count; + coordinates.store(getData(), "Signs." + count); + getData().set("Signs." + count + ".Data", data); + getData().set("Signs." + count + ".Position", position); + }); + String count = Integer.toString(selected[0]); + plugin.getSigns().getSigns().add(new SignHandler(plugin, count, getSignLocation(count), + getSignSkullLocation(count), getSignData(count), getSignPosition(count))); } /** @@ -86,11 +265,13 @@ public void addSign(Location location, String data, int position) { * @param num the vote number * @param vote the vote time queue entry */ - public void addTimeVoted(int num, VoteTimeQueue vote) { - getData().set("TimedVoteCache." + num + ".Name", vote.getName()); - getData().set("TimedVoteCache." + num + ".Service", vote.getService()); - getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); - saveData(); + public void addTimeVoted(int num, VoteTimeQueue vote) { + mutateData(() -> { + getData().set("TimedVoteCache." + num + ".Name", vote.getName()); + getData().set("TimedVoteCache." + num + ".Service", vote.getService()); + getData().set("TimedVoteCache." + num + ".Time", vote.getTime()); + saveTimedVoteCache(); + }); } /** @@ -98,16 +279,20 @@ public void addTimeVoted(int num, VoteTimeQueue vote) { * * @param ident the vote shop identifier */ - public void addVoteShopPurchase(String ident) { - setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1)); + public void addVoteShopPurchase(String ident) { + mutateData(() -> { + setVoteShopPurchases(ident, (getVoteShopPurchases(ident) + 1)); + }); } /** * Clears the timed vote cache. */ - public void clearTimedVoteCache() { - getData().set("TimedVoteCache", null); - saveData(); + public void clearTimedVoteCache() { + mutateData(() -> { + getData().set("TimedVoteCache", null); + saveTimedVoteCache(); + }); } /** @@ -142,13 +327,15 @@ public int getBungeeVotePartyRequired() { * * @return the data */ - public ConfigurationSection getData() { - ConfigurationSection data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin"); - if (data == null) { - plugin.getServerDataFile().getData().createSection("VotingPlugin"); - data = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin"); - } - return data; + public ConfigurationSection getData() { + var fence = getPublicationFence(); + try (var owner = fence == null ? null : fence.acquire()) { + synchronized (publicationMonitor()) { + ConfigurationSection data = getFile().getData().getConfigurationSection("VotingPlugin"); + if (data == null) data = getFile().getData().createSection("VotingPlugin"); + return data; + } + } } /** @@ -165,9 +352,10 @@ public List getDisabledReminders() { * * @return the service sites */ - @SuppressWarnings("unchecked") - public ArrayList getServiceSites() { - return (ArrayList) getData().getList("GottenServiceSites", new ArrayList<>()); + public ArrayList getServiceSites() { + synchronized (publicationMonitor()) { + return new ArrayList<>(getData().getStringList("GottenServiceSites")); + } } /** @@ -176,9 +364,11 @@ public ArrayList getServiceSites() { * * @return persisted service-site observations */ - public synchronized List getServiceSitesReadOnly() { - ConfigurationSection root = plugin.getServerDataFile().getData().getConfigurationSection("VotingPlugin"); - return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites")); + public List getServiceSitesReadOnly() { + synchronized (publicationMonitor()) { + ConfigurationSection root = getFile().getData().getConfigurationSection("VotingPlugin"); + return root == null ? List.of() : List.copyOf(root.getStringList("GottenServiceSites")); + } } /** @@ -339,8 +529,11 @@ public int nextSignNumber() { /** * Reload data. */ - public void reloadData() { - plugin.getServerDataFile().reloadData(); + public void reloadData() { + mutateData(() -> { + getFile().reloadData(); + captureVotePartyDisplay(); + }); } /** @@ -359,53 +552,164 @@ public long getTopVoterMessageId(TopVoter top) { * @param top the top voter type * @param messageId the message ID */ - public synchronized void setTopVoterMessageId(TopVoter top, long messageId) { - getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId); - saveData(); - } - - /** - * Uses the existing writer and confirms this ID in the saved file. Legacy void saves may - * swallow I/O failure; queued/custom writers may also remain unconfirmed at this point. - * False is an uncertain outcome, never permission to delete a possibly recorded message. - * Call only on the existing worker, under this facade's normal save ordering. - */ - public synchronized boolean setTopVoterMessageIdConfirmed(TopVoter top, long messageId) { - setTopVoterMessageId(top, messageId); - try { - var saved = new org.bukkit.configuration.file.YamlConfiguration(); - saved.load(plugin.getServerDataFile().getdFile()); - return saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId." + top.toString(), 0) == messageId; - } catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException | RuntimeException failure) { - return false; - } - } - - /** + public void setTopVoterMessageId(TopVoter top, long messageId) { + mutateSavedData(() -> { + getData().set("DiscordSRV.TopVoterMessageId." + top.toString(), messageId); + }); + } + + /** + * Uses the existing writer and confirms this ID in the saved file. Legacy void saves may + * swallow I/O failure; queued/custom writers may also remain unconfirmed at this point. + * False is an uncertain outcome, never permission to delete a possibly recorded message. + * Call only on the existing worker, under this facade's normal save ordering. + */ + public synchronized boolean setTopVoterMessageIdConfirmed(TopVoter top, long messageId) { + setTopVoterMessageId(top, messageId); + try { + var saved = new org.bukkit.configuration.file.YamlConfiguration(); + saved.load(plugin.getServerDataFile().getdFile()); + return saved.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId." + top.toString(), 0) == messageId; + } catch (java.io.IOException | org.bukkit.configuration.InvalidConfigurationException | RuntimeException failure) { + return false; + } + } + + /** * Removes the sign. * * @param sign the sign */ - public void removeSign(String sign) { - getData().set("Signs." + sign + ".World", null); - getData().set("Signs." + sign + ".X", null); - getData().set("Signs." + sign + ".Y", null); - getData().set("Signs." + sign + ".Z", null); - getData().set("Signs." + sign + ".Data", null); - getData().set("Signs." + sign + ".Position", null); - getData().set("Signs." + sign, null); - getData().set("Signs." + sign + ".Skull.World", null); - getData().set("Signs." + sign + ".Skull.X", null); - getData().set("Signs." + sign + ".Skull.Y", null); - getData().set("Signs." + sign + ".Skull.Z", null); - saveData(); + public void removeSign(String sign) { + mutateSavedData(() -> { + getData().set("Signs." + sign + ".World", null); + getData().set("Signs." + sign + ".X", null); + getData().set("Signs." + sign + ".Y", null); + getData().set("Signs." + sign + ".Z", null); + getData().set("Signs." + sign + ".Data", null); + getData().set("Signs." + sign + ".Position", null); + getData().set("Signs." + sign, null); + getData().set("Signs." + sign + ".Skull.World", null); + getData().set("Signs." + sign + ".Skull.X", null); + getData().set("Signs." + sign + ".Skull.Y", null); + getData().set("Signs." + sign + ".Skull.Z", null); + }); + } + + /** Checkpoint the pending suffix without retiring its live queue entries. */ + public void replaceTimedVoteCache(java.util.Collection pending) { + mutateData(() -> { + Object previous = getData().get("TimedVoteCache"); + try { + setTimedVoteCache(pending); + saveTimedVoteCache(); + } catch (RuntimeException | Error failure) { + getData().set("TimedVoteCache", previous); + throw failure; + } + }); + } + + /** Publish the recovery suffix and conservative in-flight record in one file save. */ + public void prepareTimedVoteDispatch(UUID id, VoteTimeQueue vote, + java.util.Collection pending) { + mutateData(() -> { + String path = "UncertainTimedVotes." + id; + Object previousCache = getData().get("TimedVoteCache"); + Object previousRecord = getData().get(path); + try { + setTimedVoteCache(pending); + getData().set(path, null); + getData().createSection(path, timedVoteFields(vote)); + saveTimedVoteCache(); + } catch (RuntimeException | Error failure) { + getData().set("TimedVoteCache", previousCache); + getData().set(path, previousRecord); + throw failure; + } + }); + } + + /** Restore an undispatched occurrence and its pending suffix for ordinary restart recovery. */ + public void abortTimedVoteDispatch(UUID id, java.util.Collection pending) { + mutateData(() -> { + String path = "UncertainTimedVotes." + id; + Object previousCache = getData().get("TimedVoteCache"); + Object previousRecord = getData().get(path); + try { + setTimedVoteCache(pending); + getData().set(path, null); + saveTimedVoteCache(); + } catch (RuntimeException | Error failure) { + getData().set("TimedVoteCache", previousCache); + getData().set(path, previousRecord); + throw failure; + } + }); + } + + /** Clear only a confirmed dispatch record; failed writes retain its live representation. */ + public void completeTimedVoteDispatch(UUID id) { + mutateData(() -> { + String path = "UncertainTimedVotes." + id; + Object previous = getData().get(path); + try { + getData().set(path, null); + saveTimedVoteCache(); + } catch (RuntimeException | Error failure) { + getData().set(path, previous); + throw failure; + } + }); } - /** - * Save data. - */ - public synchronized void saveData() { - plugin.getServerDataFile().saveData(); + private void setTimedVoteCache(java.util.Collection pending) { + java.util.Map rows = new java.util.LinkedHashMap<>(); + int index = 0; + for (VoteTimeQueue vote : pending) rows.put(Integer.toString(index++), timedVoteFields(vote)); + getData().set("TimedVoteCache", null); + getData().createSection("TimedVoteCache", rows); + } + + private static java.util.Map timedVoteFields(VoteTimeQueue vote) { + java.util.Map fields = new java.util.LinkedHashMap<>(); + fields.put("Name", vote.getName()); + fields.put("Service", vote.getService()); + fields.put("Time", vote.getTime()); + return fields; + } + + /** Retain an ambiguous event delivery without making it eligible for automatic replay. */ + public void addUncertainTimedVote(java.util.UUID id, VoteTimeQueue vote) { + mutateData(() -> { + String path = "UncertainTimedVotes." + id; + getData().set(path + ".Name", vote.getName()); + getData().set(path + ".Service", vote.getService()); + getData().set(path + ".Time", vote.getTime()); + saveTimedVoteCache(); + }); + } + + /** Timed votes must observe write failures before their in-memory queue is retired. */ + private void saveTimedVoteCache() { + var fence = getPublicationFence(); + try (var owner = fence == null ? null : fence.acquire()) { + var file = getFile(); + if (file instanceof GenerationFencedServerDataFile guarded) guarded.saveDataChecked(); + else file.getData().save(file.getdFile()); + } catch (java.io.IOException failure) { + throw new java.io.UncheckedIOException("Failed to save timed vote cache", failure); + } + } + + /** Saves server data through the existing general-purpose file helper. */ + public void saveData() { + mutateData(() -> { + // Publish the complete live display projection before entering disk I/O. + // A failed save still does not acknowledge durable storage. + captureVotePartyDisplay(); + getFile().saveData(); + }); } /** @@ -413,13 +717,10 @@ public synchronized void saveData() { * * @param disabledReminders the list of UUIDs with disabled reminders */ - public void saveDisabledReminders(ArrayList disabledReminders) { - ArrayList uuids = new ArrayList<>(); - for (UUID uuid : disabledReminders) { - uuids.add(uuid.toString()); - } - getData().set("DisabledReminders", uuids); - saveData(); + public void saveDisabledReminders(ArrayList disabledReminders) { + ArrayList uuids = new ArrayList<>(); + for (UUID uuid : disabledReminders) uuids.add(uuid.toString()); + setStringList("DisabledReminders", uuids); } /** @@ -427,9 +728,8 @@ public void saveDisabledReminders(ArrayList disabledReminders) { * * @param placeholders the list of placeholders */ - public void setAutoCachedPlaceholder(List placeholders) { - getData().set("AutoCachePlaceholders", placeholders); - saveData(); + public void setAutoCachedPlaceholder(List placeholders) { + setStringList("AutoCachePlaceholders", placeholders); } /** @@ -437,9 +737,8 @@ public void setAutoCachedPlaceholder(List placeholders) { * * @param current the current vote count */ - public void setBungeeVotePartyCurrent(int current) { - getData().set("BungeeVotePartyCurrent", current); - saveData(); + public void setBungeeVotePartyCurrent(int current) { + setInt("BungeeVotePartyCurrent", current); } /** @@ -447,9 +746,8 @@ public void setBungeeVotePartyCurrent(int current) { * * @param required the required vote count */ - public void setBungeeVotePartyRequired(int required) { - getData().set("BungeeVotePartyRequired", required); - saveData(); + public void setBungeeVotePartyRequired(int required) { + setInt("BungeeVotePartyRequired", required); } /** @@ -457,9 +755,8 @@ public void setBungeeVotePartyRequired(int required) { * * @param list the service sites list */ - public void setServiceSites(ArrayList list) { - getData().set("GottenServiceSites", list); - saveData(); + public void setServiceSites(ArrayList list) { + setStringList("GottenServiceSites", list); } /** @@ -467,9 +764,10 @@ public void setServiceSites(ArrayList list) { * * @param value the converted status */ - public void setShopConverted(boolean value) { - getData().set("VoteShopConverted", value); - saveData(); + public void setShopConverted(boolean value) { + mutateSavedData(() -> { + getData().set("VoteShopConverted", value); + }); } /** @@ -481,21 +779,15 @@ public void setShopConverted(boolean value) { * @param data the sign data * @param position the sign position */ - public void setSign(String count, Location location, Location skullLocation, String data, int position) { - - getData().set("Signs." + count + ".World", location.getWorld().getName()); - getData().set("Signs." + count + ".X", (int) location.getX()); - getData().set("Signs." + count + ".Y", (int) location.getY()); - getData().set("Signs." + count + ".Z", (int) location.getZ()); - getData().set("Signs." + count + ".Data", data); - getData().set("Signs." + count + ".Position", position); - if (skullLocation != null) { - getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName()); - getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX()); - getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY()); - getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ()); - } - saveData(); + public void setSign(String count, Location location, Location skullLocation, String data, int position) { + var coordinates = SignCoordinates.capture(location); + var skull = skullLocation == null ? null : SignCoordinates.capture(skullLocation); + mutateSavedData(() -> { + coordinates.store(getData(), "Signs." + count); + getData().set("Signs." + count + ".Data", data); + getData().set("Signs." + count + ".Position", position); + if (skull != null) skull.store(getData(), "Signs." + count + ".Skull"); + }); } /** @@ -504,21 +796,20 @@ public void setSign(String count, Location location, Location skullLocation, Str * @param count the sign count * @param skullLocation the skull location */ - public void setSkullLocation(String count, Location skullLocation) { - if (skullLocation != null) { - getData().set("Signs." + count + ".Skull.World", skullLocation.getWorld().getName()); - getData().set("Signs." + count + ".Skull.X", (int) skullLocation.getX()); - getData().set("Signs." + count + ".Skull.Y", (int) skullLocation.getY()); - getData().set("Signs." + count + ".Skull.Z", (int) skullLocation.getZ()); - } + public void setSkullLocation(String count, Location skullLocation) { + if (skullLocation == null) return; + var coordinates = SignCoordinates.capture(skullLocation); + mutateData(() -> coordinates.store(getData(), "Signs." + count + ".Skull")); } /** * Sets the version. */ - public void setVersion() { - getData().set("Version", Bukkit.getVersion()); - saveData(); + public void setVersion() { + String version = Bukkit.getVersion(); + mutateSavedData(() -> { + getData().set("Version", version); + }); } /** @@ -526,9 +817,10 @@ public void setVersion() { * * @param value the extra required votes */ - public void setVotePartyExtraRequired(int value) { - getData().set("VotePartyExtraRequired", value); - saveData(); + public void setVotePartyExtraRequired(int value) { + mutateSavedData(() -> { + getData().set("VotePartyExtraRequired", value); + }); } /** @@ -537,42 +829,45 @@ public void setVotePartyExtraRequired(int value) { * @param ident the vote shop identifier * @param amount the purchase amount */ - public void setVoteShopPurchases(String ident, int amount) { - getData().set("VoteShopPurchases." + ident, amount); - saveData(); + public void setVoteShopPurchases(String ident, int amount) { + mutateSavedData(() -> { + getData().set("VoteShopPurchases." + ident, amount); + }); } /** * Updates the last vote party timestamp to today. */ - public void updateLastVoteParty() { - getData().set("LastVoteParty", plugin.getTimeChecker().getTime().getDayOfYear()); - saveData(); + public void updateLastVoteParty() { + int day = plugin.getTimeChecker().getTime().getDayOfYear(); + setInt("LastVoteParty", day); } /** * Updates the last vote party week timestamp to this week. */ - public void updateLastVotePartyWeek() { - getData().set("LastVotePartyWeek", - plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear())); - saveData(); + public void updateLastVotePartyWeek() { + int week = plugin.getTimeChecker().getTime().get(WeekFields.of(Locale.getDefault()).weekOfYear()); + setInt("LastVotePartyWeek", week); } /** * Updates placeholders to lowercase format. */ - public void updatePlaceholders() { - boolean data = getData().getBoolean("AutoCacheUpdated", false); - if (!data) { - List placeholders = getAutoCachedPlaceholder(); - for (int i = 0; i < placeholders.size(); i++) { - placeholders.set(i, placeholders.get(i).toLowerCase()); - } - setAutoCachedPlaceholder(placeholders); - getData().set("AutoCacheUpdated", true); - saveData(); - } + public void updatePlaceholders() { + mutateData(() -> { + boolean data = getData().getBoolean("AutoCacheUpdated", false); + if (!data) { + mutateSavedData(() -> { + List placeholders = getAutoCachedPlaceholder(); + for (int i = 0; i < placeholders.size(); i++) { + placeholders.set(i, placeholders.get(i).toLowerCase()); + } + getData().set("AutoCachePlaceholders", placeholders); + getData().set("AutoCacheUpdated", true); + }); + } + }); } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java index 5e7188a5f9..0ff51d3301 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/listeners/VotiferEvent.java @@ -1,6 +1,5 @@ package com.bencodez.votingplugin.listeners; -import java.util.concurrent.RejectedExecutionException; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -135,9 +134,8 @@ public void onVotiferEvent(VotifierEvent event) { plugin.debug("VoteSite: " + voteSite); plugin.debug("IP: " + IP); - try { - plugin.getVoteTimer().submit(() -> processVote(voteSite, voteUsername)); - } catch (RejectedExecutionException rejected) { + if (!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), + () -> processVote(voteSite, voteUsername))) { VotifierVoteOverflowQueue overflow = plugin.getVotifierVoteOverflowQueue(); if (overflow == null || !overflow.enqueue(voteUsername, voteSite)) { plugin.getLogger().severe("Votifier vote queue is full; vote was not admitted for " diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index dd9549a57f..87a136604a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -960,7 +960,7 @@ public String placeholderRequest(String identifier) { @Override public String placeholderRequest(String identifier) { - return Integer.toString(plugin.getVoteParty().getTotalVotes()); + return Integer.toString(plugin.getServerData().getVotePartyDisplay().total()); } }.withDescription("Current amount of voteparty votes")); @@ -968,7 +968,8 @@ public String placeholderRequest(String identifier) { @Override public String placeholderRequest(String identifier) { - return Integer.toString(plugin.getVoteParty().getNeededVotes()); + return Integer.toString(plugin.getServerData().getVotePartyDisplay() + .needed(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired())); } }.withDescription("Voteparty votes needed")); @@ -976,7 +977,8 @@ public String placeholderRequest(String identifier) { @Override public String placeholderRequest(String identifier) { - return Integer.toString(plugin.getVoteParty().getVotesRequired()); + return Integer.toString(plugin.getServerData().getVotePartyDisplay() + .required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired())); } }.withDescription("Amount of votes needed for voteparty")); 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 654a88d450..fc3558620e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/servicesites/ServiceSiteHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/servicesites/ServiceSiteHandler.java @@ -123,8 +123,7 @@ public void loadFromServerData() { public void saveToServerData() { List out = toListFormat(sitesRef.get()); - plugin.getServerData().getData().set(SERVERDATA_PATH, out); - plugin.getServerData().saveData(); + plugin.getServerData().setStringList(SERVERDATA_PATH, out); } public void refreshFromWebOnce(boolean logMore) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/SignHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/SignHandler.java index b16d451245..9145688452 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/SignHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/SignHandler.java @@ -286,6 +286,12 @@ public void removeSign() { /** * Store sign. */ + /** Capture platform state on the caller before shutdown transfers publication ownership. */ + public com.bencodez.votingplugin.data.ServerData.SignSnapshot captureShutdownSnapshot() { + return com.bencodez.votingplugin.data.ServerData.captureSignSnapshot( + sign, location, skullLocation, data, position); + } + public void storeSign() { plugin.getServerData().setSign(sign, location, skullLocation, data, position); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/Signs.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/Signs.java index 7d540bab40..98a70e9832 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/Signs.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/signs/Signs.java @@ -64,6 +64,69 @@ public void storeSigns() { } } + private final Object shutdownLock = new Object(); + private ShutdownSigns shutdownSigns; + private java.util.concurrent.CompletableFuture shutdownAttempt; + + private record ShutdownSigns(com.bencodez.votingplugin.data.ServerData data, + java.util.List snapshot, + com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease) { } + + /** No YAML lock or disk wait on the lifecycle caller; failures retain the original payload. */ + public java.util.concurrent.CompletionStage storeSignsForShutdown() { + synchronized (shutdownLock) { + if (shutdownAttempt != null && !shutdownAttempt.isCompletedExceptionally()) + return shutdownAttempt.minimalCompletionStage(); + if (shutdownSigns == null) { + var snapshot = new java.util.ArrayList(); + for (var sign : java.util.List.copyOf(getSigns())) snapshot.add(sign.captureShutdownSnapshot()); + if (snapshot.isEmpty()) { + shutdownAttempt = java.util.concurrent.CompletableFuture.completedFuture(null); + return shutdownAttempt.minimalCompletionStage(); + } + var data = java.util.Objects.requireNonNull(plugin.getServerData()); + var fence = java.util.Objects.requireNonNull(plugin.getServerDataPublicationFence()); + shutdownSigns = new ShutdownSigns(data, java.util.List.copyOf(snapshot), fence.acquire()); + } + var publication = shutdownSigns; + var attempt = new java.util.concurrent.CompletableFuture(); + shutdownAttempt = attempt; + try { + startShutdownWorker(() -> { + try { + java.util.Objects.requireNonNull(publication.data().publishShutdownSigns(publication.snapshot())) + .whenComplete((ignored, failure) -> finishShutdownSigns(publication, attempt, failure)); + } catch (Throwable failure) { finishShutdownSigns(publication, attempt, failure); } + }); + } catch (RuntimeException | Error failure) { + finishShutdownSigns(publication, attempt, failure); + throw failure; + } + return attempt.minimalCompletionStage(); + } + } + + private void finishShutdownSigns(ShutdownSigns publication, + java.util.concurrent.CompletableFuture attempt, Throwable failure) { + if (failure == null) { + try { publication.lease().close(); attempt.complete(null); } + catch (Throwable releaseFailure) { attempt.completeExceptionally(releaseFailure); } + } else { + // Neither rejected admission nor failed I/O proves durability or writer drain. + // Keep the same detached payload and lease for an explicit repeated-unload attempt. + try { plugin.getLogger().warning("Sign shutdown persistence failed; its payload remains owned. Recovery is not confirmed."); } + catch (RuntimeException | Error ignored) { } + attempt.completeExceptionally(failure); + } + } + + /** Admission seam for deterministic rejection/cancellation tests. */ + protected void startShutdownWorker(Runnable task) { + var worker = new Thread(task, "VotingPlugin-sign-shutdown"); + worker.setDaemon(true); + worker.start(); + } + /** * Update signs. */ diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java index 98d71533b1..62ca65dd2a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VoteParty.java @@ -146,8 +146,9 @@ public void checkVoteReminder(VotingPluginUser user) { public void commandVoteParty(CommandSender sender) { if (plugin.getSpecialRewardsConfig().isVotePartyEnabled()) { ArrayList msg = plugin.getConfigFile().getFormatCommandsVoteParty(); - int votesRequired = getVotesRequired(); - int votes = getTotalVotes(); + var display = plugin.getServerData().getVotePartyDisplay(); + int votesRequired = display.required(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()); + int votes = display.total(); int neededVotes = votesRequired - votes; HashMap placeholders = new HashMap<>(); placeholders.put("votesrequired", "" + votesRequired); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java index 99d31ce860..4b8ec1544c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/specialrewards/voteparty/VotePartyState.java @@ -24,8 +24,7 @@ public int getTotalVotes() { } public void setTotalVotes(int value) { - plugin.getServerData().getData().set(TOTAL_PATH, value); - plugin.getServerData().saveData(); + plugin.getServerData().setInt(TOTAL_PATH, value); } public List getVotedUsers() { @@ -34,8 +33,7 @@ public List getVotedUsers() { } public void setVotedUsers(List users) { - plugin.getServerData().getData().set(VOTED_PATH, users); - plugin.getServerData().saveData(); + plugin.getServerData().setStringList(VOTED_PATH, users); } public int getVotesRequired() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java index b1e46f3ef4..21359c0791 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/timequeue/TimeQueueHandler.java @@ -3,10 +3,15 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Queue; +import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.FutureTask; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.event.EventHandler; @@ -24,11 +29,24 @@ * The TimeQueueHandler class manages time-based vote queue processing. */ public class TimeQueueHandler implements Listener { - private static final long TICKS_PER_SECOND = 20L; @Getter private Queue timeChangeQueue = new ConcurrentLinkedQueue<>(); private VotingPluginMain plugin; + private final Object persistenceLock = new Object(); + private volatile boolean stopping; + private final AtomicReference> shutdownSave = new AtomicReference<>(); + // A failed task is retryable, but cannot release its unsaved suffix to a successor. + private final AtomicReference shutdownPublicationOwner = new AtomicReference<>(); + private record ShutdownPublication(com.bencodez.votingplugin.util.ServerDataPublicationFence.Lease lease, + java.util.concurrent.ScheduledExecutorService producers, + java.util.concurrent.ScheduledExecutorService[] coreWriters) { } + private final AtomicBoolean processing = new AtomicBoolean(); + private VoteTimeQueue dispatchingVote; + private UUID dispatchId; + private boolean dispatchEnded; + private boolean dispatchStarted; + private int persistedShutdownVotes; private final AtomicBoolean retryPending = new AtomicBoolean(); private final AtomicInteger retryAttempts = new AtomicInteger(); @@ -49,8 +67,13 @@ public TimeQueueHandler(VotingPluginMain plugin) { * @param voteSiteName the vote site name */ public void addVote(String voteUsername, String voteSiteName) { - timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName, - LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + synchronized (persistenceLock) { + timeChangeQueue.add(new VoteTimeQueue(voteUsername, voteSiteName, + LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())); + // A vote task can outlive shutdownNow(). Persist its late admission + // through the same owner rather than appending to an already-saved queue. + if (stopping) persistPendingVotes(); + } } /** @@ -76,17 +99,11 @@ public void postTimeChange(DateChangedEvent event) { } private void scheduleQueueProcessing(long delay, TimeUnit unit) { - boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), () -> { - // Clear only after the bounded executor has admitted the task. If it is - // rejected, shutdown persistence can still recover the in-memory queue. - plugin.getServerData().clearTimedVoteCache(); - processQueue(); - }, delay, unit); + synchronized (persistenceLock) { if (stopping) return; } + boolean admitted = VoteTaskAdmission.trySchedule(plugin.getVoteTimer(), this::processQueue, delay, unit); if (!admitted) { plugin.getLogger().warning("Unable to schedule time-queue processing because vote processing is busy; queued votes were retained."); scheduleRetry(); - } else { - retryAttempts.set(0); } } @@ -94,15 +111,14 @@ private void scheduleRetry() { if (timeChangeQueue.isEmpty() || !retryPending.compareAndSet(false, true)) return; int attempt = Math.min(6, retryAttempts.getAndIncrement()); long delaySeconds = Math.min(60L, 1L << attempt); - long delayTicks = delaySeconds * TICKS_PER_SECOND; try { plugin.getBukkitScheduler().runTaskLaterAsynchronously(plugin, () -> { retryPending.set(false); if (!timeChangeQueue.isEmpty()) scheduleQueueProcessing(0, TimeUnit.SECONDS); - }, delayTicks); + }, delaySeconds); } catch (RuntimeException rejected) { retryPending.set(false); - plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain persisted for recovery."); + plugin.getLogger().warning("Unable to queue a time-queue retry; pending votes remain owned for recovery."); } } @@ -110,32 +126,239 @@ private void scheduleRetry() { * Processes all votes in the queue. */ public void processQueue() { - while (getTimeChangeQueue().size() > 0) { - VoteTimeQueue vote = getTimeChangeQueue().remove(); - PlayerVoteEvent voteEvent = new PlayerVoteEvent( - plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), - vote.getService(), true); - voteEvent.setTime(vote.getTime()); - plugin.getServer().getPluginManager().callEvent(voteEvent); - - if (voteEvent.isCancelled()) { - plugin.debug("Vote cancelled"); - continue; + if (!processing.compareAndSet(false, true)) return; + boolean retry = false; + try { + synchronized (persistenceLock) { + if (stopping) return; + if (dispatchingVote != null) { + if (!dispatchEnded) return; + retainUncertainDispatch(); + clearDispatch(); + } + persistedShutdownVotes = 0; + } + while (true) { + VoteTimeQueue vote; + synchronized (persistenceLock) { + if (stopping || dispatchingVote != null) return; + vote = timeChangeQueue.peek(); + } + if (vote == null) return; + PlayerVoteEvent voteEvent = new PlayerVoteEvent( + plugin.getVoteSiteManager().getVoteSite(plugin.getVoteSiteManager().getVoteSiteName(true, vote.getService()), true), vote.getName(), + vote.getService(), true); + voteEvent.setTime(vote.getTime()); + synchronized (persistenceLock) { + if (stopping) return; + if (timeChangeQueue.peek() != vote) continue; + dispatchingVote = vote; + if (vote.getVoteId() == null) vote.setVoteId(UUID.randomUUID()); + dispatchId = vote.getVoteId(); + dispatchEnded = false; + try { + plugin.getServerData().prepareTimedVoteDispatch(dispatchId, vote, + timeChangeQueue.stream().filter(pending -> pending != vote).toList()); + } catch (RuntimeException | Error failure) { + clearDispatch(); + throw failure; + } + // A preparation resumed after stop is still eligible for normal recovery. + if (stopping) return; + dispatchStarted = true; + } + try { + plugin.getServer().getPluginManager().callEvent(voteEvent); + } catch (RuntimeException | Error failure) { + synchronized (persistenceLock) { + dispatchEnded = true; + try { + retainUncertainDispatch(); + clearDispatch(); + if (!stopping) checkpointPendingVotes(); + } catch (RuntimeException | Error persistenceFailure) { + if (failure != persistenceFailure) failure.addSuppressed(persistenceFailure); + } + } + throw failure; + } + synchronized (persistenceLock) { + dispatchEnded = true; + if (!stopping) plugin.getServerData().completeTimedVoteDispatch(dispatchId); + timeChangeQueue.remove(vote); + clearDispatch(); + if (voteEvent.isCancelled() && !stopping) checkpointPendingVotes(); + } + if (voteEvent.isCancelled()) { + plugin.debug("Vote cancelled"); + continue; + } } + } catch (RuntimeException | Error failure) { + retry = !stopping; + throw failure; + } finally { + processing.set(false); + if (retry) scheduleRetry(); + else retryAttempts.set(0); } } + private void clearDispatch() { + dispatchingVote = null; + dispatchId = null; + dispatchEnded = false; + dispatchStarted = false; + } + + private void checkpointPendingVotes() { + plugin.getServerData().replaceTimedVoteCache( + timeChangeQueue.stream().filter(vote -> vote != dispatchingVote).toList()); + persistedShutdownVotes = 0; + } + + /** Uncertain event delivery is retained for review, never automatically replayed. */ + private void retainUncertainDispatch() { + if (dispatchingVote == null || !timeChangeQueue.contains(dispatchingVote)) return; + plugin.getServerData().addUncertainTimedVote(dispatchId, dispatchingVote); + timeChangeQueue.remove(dispatchingVote); + plugin.getLogger().warning("Timed vote event delivery is uncertain; retained in ServerData.yml VotingPlugin.UncertainTimedVotes for manual review."); + } + /** * Saves pending votes to server data. */ public void save() { - if (!timeChangeQueue.isEmpty()) { - int num = 0; - for (VoteTimeQueue vote : timeChangeQueue) { - plugin.getServerData().addTimeVoted(num, vote); - num++; + if (stopping) { + saveForShutdown(); + return; + } + synchronized (persistenceLock) { + if (stopping) persistPendingVotes(); + else checkpointPendingVotes(); + } + } + + /** Seal processing and retain one persistence owner without an unbounded lifecycle wait. */ + public void saveForShutdown() { + stopping = true; + FutureTask save; + while (true) { + save = shutdownSave.get(); + if (save != null) { + if (!save.isDone()) break; + shutdownSave.compareAndSet(save, null); + continue; + } + FutureTask candidate = new FutureTask(() -> { + try { + var publication = java.util.Objects.requireNonNull(shutdownPublicationOwner.get()); + try { synchronized (persistenceLock) { persistPendingVotes(); } } + finally { + // Publication failure does not terminate previously admitted producers. + awaitRetiringProducers(publication.producers()); + if (publication.coreWriters() != null) + for (var writer : publication.coreWriters()) awaitWriterTermination(writer); + } + synchronized (persistenceLock) { persistPendingVotes(); } + if (publication.lease() != null) publication.lease().close(); + shutdownPublicationOwner.compareAndSet(publication, null); + } catch (RuntimeException | Error failure) { + try { + plugin.getLogger().warning("Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed."); + } catch (RuntimeException | Error ignored) { } + throw failure; + } + return null; + }) { + @Override protected void done() { + // Late admission failures must be retryable after this owner completes. + shutdownSave.compareAndSet(this, null); + } + }; + FutureTask existing = shutdownSave.compareAndExchange(null, candidate); + if (existing == null) { + save = candidate; + try { + if (shutdownPublicationOwner.get() == null) { + // Unload can clear these fields even while their original work survives. + // Keep the snapshot with its lease through every rejected or failed attempt. + var producers = plugin.getVoteTimer(); + var coreWriters = plugin.getServerDataWriterExecutors(); + var retainedWriters = coreWriters == null ? null : coreWriters.clone(); + var fence = plugin.getServerDataPublicationFence(); + shutdownPublicationOwner.set(new ShutdownPublication( + fence == null ? null : fence.acquire(), producers, retainedWriters)); + } + startShutdownWorker(candidate); + } + catch (RuntimeException | Error failure) { + // Even a rejected worker retains the queue owner for an explicit retry. + candidate.cancel(false); + shutdownSave.compareAndSet(candidate, null); + throw failure; + } + break; + } + } + try { + save.get(1, TimeUnit.SECONDS); + shutdownSave.compareAndSet(save, null); + } catch (TimeoutException pending) { + plugin.getLogger().warning("Timed vote shutdown persistence is still pending; its worker retains ownership. Recovery is not yet confirmed."); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + plugin.getLogger().warning("Timed vote shutdown persistence wait was interrupted; its worker retains ownership."); + } catch (ExecutionException failure) { + shutdownSave.compareAndSet(save, null); + Throwable cause = failure.getCause(); + if (cause instanceof Error error) throw error; + if (cause instanceof RuntimeException runtime) throw runtime; + throw new IllegalStateException("Timed vote shutdown persistence failed", cause); + } + } + + /** Kept separate so worker-admission failure can be exercised without replacing persistence. */ + protected void startShutdownWorker(Runnable task) { + Thread worker = new Thread(task, "VotingPlugin-timed-vote-shutdown"); + worker.setDaemon(true); + worker.start(); + } + + private void awaitRetiringProducers(java.util.concurrent.ScheduledExecutorService producers) { + if (producers == null || !producers.isShutdown()) return; + awaitWriterTermination(producers); + } + + private void awaitWriterTermination(java.util.concurrent.ScheduledExecutorService producers) { + if (producers == null) return; + boolean interrupted = false; + try { + while (!producers.isTerminated()) { + try { producers.awaitTermination(100, TimeUnit.MILLISECONDS); } + catch (InterruptedException ignored) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + } + + /** Remove only a successfully saved prefix; failures retain the same next slot for retry. */ + private void persistPendingVotes() { + VoteTimeQueue vote; + while ((vote = timeChangeQueue.peek()) != null) { + if (vote == dispatchingVote) { + if (dispatchStarted) retainUncertainDispatch(); + else { + var pending = timeChangeQueue.stream().toList(); + plugin.getServerData().abortTimedVoteDispatch(dispatchId, pending); + timeChangeQueue.removeAll(pending); + persistedShutdownVotes = pending.size(); + clearDispatch(); + } + } else { + plugin.getServerData().addTimeVoted(persistedShutdownVotes, vote); + persistedShutdownVotes++; + timeChangeQueue.remove(vote); } } - timeChangeQueue.clear(); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServerDataPublicationFence.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServerDataPublicationFence.java new file mode 100644 index 0000000000..1e0788612d --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/ServerDataPublicationFence.java @@ -0,0 +1,217 @@ +package com.bencodez.votingplugin.util; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.Locale; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** Memory-only admission for participating plugin generations sharing one server-data path in a JVM. */ +public final class ServerDataPublicationFence { + private static final String PREFIX = "com.bencodez.votingplugin.ServerDataPublicationFence.owner."; + private static final int MAX_OWNERS = 1024; + private final Properties registry; + private final String key; + private final String generation; + private final AtomicBoolean retirementStarted = new AtomicBoolean(); + private final java.util.concurrent.CompletableFuture retirementCompleted = new java.util.concurrent.CompletableFuture<>(); + + private final CompletableFuture processShutdownStarted = new CompletableFuture<>(); + private final CompletableFuture processExitCompletion = new CompletableFuture<>(); + private final AtomicBoolean processHookRemoved = new AtomicBoolean(); + private volatile Runtime processRuntime; + private volatile Thread processHook; + + /** Opt in during startup, before the JVM can begin invoking shutdown hooks. */ + public synchronized void retainForProcessExit() { + if (processHook != null) return; + var runtime = Runtime.getRuntime(); + var hook = new Thread(this::awaitProcessExit, "VotingPlugin-ServerData-Exit"); + runtime.addShutdownHook(hook); + processRuntime = runtime; + processHook = hook; + processExitCompletion.whenComplete((ignored, failure) -> removeProcessHook()); + } + + /** The first disable entry fixes the deadline; retries cannot renew it. No caller wait. */ + public void beginShutdown(long startedNanos) { + if (processShutdownStarted.complete(startedNanos) && processHook != null) { + CompletableFuture.delayedExecutor(remainingProcessWait(startedNanos), TimeUnit.NANOSECONDS) + .execute(this::removeProcessHook); + } + } + + private static long remainingProcessWait(long startedNanos) { + long elapsed = Math.max(0L, System.nanoTime() - startedNanos); + return Math.max(0L, TimeUnit.SECONDS.toNanos(15) - elapsed); + } + + private void awaitProcessExit() { + long exitStarted = System.nanoTime(); + try { + long disableStarted = processShutdownStarted.get(remainingProcessWait(exitStarted), TimeUnit.NANOSECONDS); + long remaining = Math.min(remainingProcessWait(exitStarted), remainingProcessWait(disableStarted)); + if (remaining > 0) processExitCompletion.get(remaining, TimeUnit.NANOSECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (ExecutionException | TimeoutException pendingOrExpired) { + // Process exit is bounded. Timeout does not acknowledge publication or release a lease. + } + } + + private void removeProcessHook() { + var hook = processHook; + if (hook == null || !processHookRemoved.compareAndSet(false, true)) return; + try { processRuntime.removeShutdownHook(hook); } + catch (IllegalStateException stopping) { /* The JVM is already invoking its hooks. */ } + } + + private ServerDataPublicationFence(Properties registry, String key, String generation) { + this.registry = registry; + this.key = key; + this.generation = generation; + } + + /** Runs before platform initialization and performs no filesystem operation or wait for storage. */ + public static ServerDataPublicationFence beginGeneration(Path file) { + String identity = file.toAbsolutePath().normalize().toString(); + if (DurableFiles.isWindowsName(System.getProperty("os.name", ""))) identity = identity.toLowerCase(Locale.ROOT); + String key; + try { + key = PREFIX + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(identity.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); } + Properties registry = System.getProperties(); + synchronized (registry) { + Object value = registry.get(key); + if (value != null) { + State previous = state(value); + if (previous.users != 0 || !previous.retiring) + throw new IllegalStateException("Previous server-data generation still owns storage work; replacement was not started"); + } else if (registry.keySet().stream().filter(k -> k instanceof String s && s.startsWith(PREFIX)).count() >= MAX_OWNERS) { + throw new IllegalStateException("Server-data publication owner capacity is unavailable"); + } + String generation = UUID.randomUUID().toString(); + registry.setProperty(key, generation + ":0:false"); + return new ServerDataPublicationFence(registry, key, generation); + } + } + + /** Hold through the entire operation, including a blocked write or admitted shutdown handoff. */ + public Lease acquire() { + synchronized (registry) { + State current = current(); + if (current.users >= MAX_OWNERS) throw new IllegalStateException("Server-data publication admission is unavailable"); + registry.setProperty(key, generation + ":" + (current.users + 1) + ":" + current.retiring); + return new Lease(this); + } + } + + /** Existing operations/retries retain ownership; a successor requires zero outstanding leases. */ + public void retire() { + boolean drained = false; + synchronized (registry) { + State current = state(registry.get(key)); + if (generation.equals(current.generation)) { + registry.setProperty(key, generation + ":" + current.users + ":true"); + drained = current.users == 0; + } + } + // Completion callbacks may remove a JVM hook; never run them under the registry monitor. + if (drained) processExitCompletion.complete(null); + } + + /** Reserve before asynchronous admission; a failed stage never proves drain. */ + public void retainUntilSuccessfulPublication(java.util.concurrent.CompletionStage publication) { + java.util.Objects.requireNonNull(publication); + Lease lease = acquire(); + publication.whenComplete((ignored, failure) -> { + if (failure == null) lease.close(); + }); + } + + /** Reserve before disable and release only after existing SDK writers actually terminate. */ + public java.util.concurrent.CompletionStage retainUntilTermination(java.util.concurrent.ScheduledExecutorService[] writers) { + var snapshot = writers.clone(); + if (!retirementStarted.compareAndSet(false, true)) return retirementCompleted.minimalCompletionStage(); + Lease lease; + try { lease = acquire(); } + catch (RuntimeException | Error failure) { retirementStarted.set(false); throw failure; } + try { + Thread guardian = new Thread(() -> { + boolean interrupted = false; + try (lease) { + for (var writer : snapshot) { + if (writer == null) continue; + while (!writer.isTerminated()) { + try { writer.awaitTermination(100, java.util.concurrent.TimeUnit.MILLISECONDS); } + catch (InterruptedException ignored) { interrupted = true; } + } + } + } catch (RuntimeException | Error failure) { + retirementCompleted.completeExceptionally(failure); + return; + } finally { if (interrupted) Thread.currentThread().interrupt(); } + retirementCompleted.complete(null); + }, "VotingPlugin-server-data-retirement"); + guardian.setDaemon(true); + guardian.start(); + } catch (RuntimeException | Error failure) { + try { lease.close(); } + catch (RuntimeException | Error cleanupFailure) { if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); } + finally { retirementStarted.set(false); } + throw failure; + } + return retirementCompleted.minimalCompletionStage(); + } + + private State current() { + State current = state(registry.get(key)); + if (registry != System.getProperties() || !generation.equals(current.generation)) + throw new IllegalStateException("Server-data publication belongs to another generation"); + return current; + } + + private static State state(Object value) { + if (!(value instanceof String text) || text.length() > 64) + throw new IllegalStateException("Server-data publication ownership is invalid"); + String[] fields = text.split(":", -1); + try { + if (fields.length != 3 || !UUID.fromString(fields[0]).toString().equals(fields[0]) + || !(fields[2].equals("true") || fields[2].equals("false"))) throw new IllegalArgumentException(); + int users = Integer.parseInt(fields[1]); + if (users < 0 || users > MAX_OWNERS || !Integer.toString(users).equals(fields[1])) throw new IllegalArgumentException(); + return new State(fields[0], users, Boolean.parseBoolean(fields[2])); + } catch (IllegalArgumentException invalid) { + throw new IllegalStateException("Server-data publication ownership is invalid"); + } + } + + private record State(String generation, int users, boolean retiring) { } + + public static final class Lease implements AutoCloseable { + private final ServerDataPublicationFence owner; + private final AtomicBoolean closed = new AtomicBoolean(); + private Lease(ServerDataPublicationFence owner) { this.owner = owner; } + @Override public void close() { + boolean drained; + synchronized (owner.registry) { + if (closed.get()) return; + State current = owner.current(); + if (current.users <= 0) throw new IllegalStateException("Server-data publication ownership is invalid"); + owner.registry.setProperty(owner.key, owner.generation + ":" + (current.users - 1) + ":" + current.retiring); + closed.set(true); + drained = current.retiring && current.users == 1; + } + if (drained) owner.processExitCompletion.complete(null); + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java index e749f9a99f..56c931cda3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/VoteTaskAdmission.java @@ -13,11 +13,12 @@ private VoteTaskAdmission() { /** * Attempts to submit a one-shot vote task without running it on the caller. * - * @param executor bounded vote executor + * @param executor bounded vote executor, or null before startup/after shutdown * @param task task to submit * @return true when the task was admitted */ public static boolean trySubmit(ScheduledExecutorService executor, Runnable task) { + if (executor == null) return false; try { executor.submit(task); return true; @@ -29,13 +30,14 @@ public static boolean trySubmit(ScheduledExecutorService executor, Runnable task /** * Attempts to schedule a one-shot vote task without blocking the caller. * - * @param executor bounded vote executor + * @param executor bounded vote executor, or null before startup/after shutdown * @param task task to schedule * @param delay delay before execution * @param unit delay unit * @return true when the task was admitted */ public static boolean trySchedule(ScheduledExecutorService executor, Runnable task, long delay, TimeUnit unit) { + if (executor == null) return false; try { executor.schedule(task, delay, unit); return true; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DeferredServerDataOwnerPublicationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DeferredServerDataOwnerPublicationTest.java new file mode 100644 index 0000000000..88c860180c --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DeferredServerDataOwnerPublicationTest.java @@ -0,0 +1,126 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.management.ManagementFactory; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.GenerationFencedServerDataFile; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class DeferredServerDataOwnerPublicationTest { + @TempDir Path directory; + + @Test void deferredNativePublicationSerializesCheckpointsAndRetainsItsActualAcknowledgement() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var ownerWorker = Executors.newSingleThreadExecutor(); + var checkpointWorker = Executors.newSingleThreadExecutor(); + var mutationEntered = new CountDownLatch(1); + var releaseMutation = new CountDownLatch(1); + var nativePublished = new CountDownLatch(1); + var releaseAcknowledgement = new CountDownLatch(1); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public CompletionStage mutateFinalData(Consumer mutation) { + return CompletableFuture.supplyAsync(() -> super.mutateFinalData(mutation), ownerWorker) + .thenCompose(stage -> stage.thenRun(() -> { + assertFalse(Thread.holdsLock(this), "acknowledgement must run outside YAML ownership"); + nativePublished.countDown(); + await(releaseAcknowledgement); + })); + } + }; + backing.setData(new YamlConfiguration()); + backing.getData().set("Adjacent.Value", "preserved"); + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var inner = new GenerationFencedServerDataFile(plugin, backing, fence); + var facade = new GenerationFencedServerDataFile(plugin, inner, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + data.getData(); + var checkpointThread = new AtomicReference(); + var checkpointStarted = new CountDownLatch(1); + try { + // This returns a genuinely deferred owner stage, not a mocked future. + var observation = facade.mutateFinalData(snapshot -> { + assertTrue(Thread.holdsLock(backing)); + assertEquals("preserved", snapshot.getString("Adjacent.Value")); + mutationEntered.countDown(); + await(releaseMutation); + snapshot.set("Final.Value", "confirmed"); + }).toCompletableFuture(); + assertTrue(mutationEntered.await(5, TimeUnit.SECONDS)); + assertFalse(observation.isDone()); + var checkpoint = checkpointWorker.submit(() -> { + checkpointThread.set(Thread.currentThread()); + checkpointStarted.countDown(); + data.setInt("VoteParty.Total", 7); + }); + assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS)); + var threads = ManagementFactory.getThreadMXBean(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + boolean blockedByOwner = false; + while (!checkpoint.isDone() && System.nanoTime() < deadline) { + var info = threads.getThreadInfo(checkpointThread.get().threadId()); + if (info != null && info.getThreadState() == Thread.State.BLOCKED + && info.getLockInfo() != null + && info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) { + blockedByOwner = true; + break; + } + Thread.yield(); + } + assertTrue(blockedByOwner, "ordinary checkpoints must wait for the deferred native YAML owner"); + releaseMutation.countDown(); + assertTrue(nativePublished.await(5, TimeUnit.SECONDS)); + checkpoint.get(5, TimeUnit.SECONDS); + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("confirmed", disk.getString("Final.Value")); + assertEquals("preserved", disk.getString("Adjacent.Value")); + assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total")); + fence.retire(); + assertFalse(observation.isDone(), "visible native bytes alone do not settle the owner's stage"); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + releaseAcknowledgement.countDown(); + observation.get(5, TimeUnit.SECONDS); + var successor = ServerDataPublicationFence.beginGeneration(target); + successor.retire(); + } finally { + releaseMutation.countDown(); + releaseAcknowledgement.countDown(); + ownerWorker.shutdownNow(); + checkpointWorker.shutdownNow(); + assertTrue(ownerWorker.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(checkpointWorker.awaitTermination(5, TimeUnit.SECONDS)); + fence.retire(); + } + } + } + + private static void await(CountDownLatch latch) { + try { assertTrue(latch.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DisplayUnloadIntegrationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DisplayUnloadIntegrationTest.java index 00a3a9f54a..8b1e054676 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DisplayUnloadIntegrationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/DisplayUnloadIntegrationTest.java @@ -34,6 +34,7 @@ private ScheduledExecutorService stoppedExecutor() throws Exception { } @Test void unloadSettlesPendingLeaderboardEvidenceAndRejectsLaterStreakMenuOpens() throws Exception { var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + field(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class); field(plugin, "discordHandler", discord); doReturn(true).when(plugin).isEnabled(); @@ -45,6 +46,8 @@ private ScheduledExecutorService stoppedExecutor() throws Exception { field(plugin, "backendControlConnectorLifecycle", stoppedExecutor()); field(plugin, "voteTimer", stoppedExecutor()); var signs = mock(Signs.class); doReturn(signs).when(plugin).getSigns(); + when(signs.storeSignsForShutdown()).thenReturn(CompletableFuture.completedFuture(null)); + field(plugin, "signs", signs); var pending = new CompletableFuture(); var filter = new TopVoterDisplayFilter(row -> pending); field(plugin, "topVoterDisplayFilter", filter); @@ -67,7 +70,7 @@ private ScheduledExecutorService stoppedExecutor() throws Exception { assertEquals(1, generation.get()); menu.open(player); verifyNoInteractions(player); - verify(signs).storeSigns(); + verify(signs).storeSignsForShutdown(); } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/GenerationFencedServerDataFinalPublicationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/GenerationFencedServerDataFinalPublicationTest.java new file mode 100644 index 0000000000..6413c8a480 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/GenerationFencedServerDataFinalPublicationTest.java @@ -0,0 +1,382 @@ +package com.bencodez.votingplugin.tests; + +import com.bencodez.votingplugin.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.data.GenerationFencedServerDataFile; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class GenerationFencedServerDataFinalPublicationTest { + @TempDir Path directory; + + @Test void ordinaryServiceProducerCannotRaceTheActualSDKListSnapshot() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + var snapshotEntered = new java.util.concurrent.CountDownLatch(1); + var releaseSnapshot = new java.util.concurrent.CountDownLatch(1); + var producerStarted = new java.util.concurrent.CountDownLatch(1); + var mutatedLiveList = new java.util.concurrent.CountDownLatch(1); + var firstSnapshot = new java.util.concurrent.atomic.AtomicBoolean(true); + var sites = new java.util.ArrayList() { + @Override public boolean add(String value) { + boolean added = super.add(value); + if (value.equals("SyntheticLateSite")) mutatedLiveList.countDown(); + return added; + } + @Override public java.util.stream.Stream stream() { + var stream = super.stream(); + if (!firstSnapshot.compareAndSet(true, false)) return stream; + return stream.peek(value -> { + snapshotEntered.countDown(); + try { assertTrue(releaseSnapshot.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + }); + } + }; + sites.add("SyntheticExistingSite"); + backing.getData().set("VotingPlugin.GottenServiceSites", sites); + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + var workers = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var publication = workers.submit(() -> facade.mutateFinalData(snapshot -> + snapshot.set("Final.Value", "confirmed")).toCompletableFuture().join()); + assertTrue(snapshotEntered.await(5, TimeUnit.SECONDS)); + var producer = workers.submit(() -> { + producerStarted.countDown(); + data.addServiceSite("SyntheticLateSite"); + }); + assertTrue(producerStarted.await(5, TimeUnit.SECONDS)); + boolean overlapped = mutatedLiveList.await(200, TimeUnit.MILLISECONDS); + releaseSnapshot.countDown(); + assertDoesNotThrow(() -> publication.get(5, TimeUnit.SECONDS), + "ordinary producers must not invalidate the real SDK final snapshot"); + producer.get(5, TimeUnit.SECONDS); + assertFalse(overlapped, "live service history changed while the SDK owned its snapshot"); + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals(java.util.Set.of("SyntheticExistingSite", "SyntheticLateSite"), + new java.util.HashSet<>(disk.getStringList("VotingPlugin.GottenServiceSites"))); + assertEquals("confirmed", disk.getString("Final.Value")); + } finally { + releaseSnapshot.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + fence.retire(); + } + } + } + + @Test void ordinaryWriterFamiliesMutateOnlyUnderTheActualYamlOwner() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + var yaml = new YamlConfiguration() { + @Override public void set(String path, Object value) { + assertTrue(Thread.holdsLock(backing), "YAML mutation escaped its actual owner: " + path); + super.set(path, value); + } + }; + backing.setData(yaml); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + try { + data.addServiceSite("SyntheticSite"); + data.addAutoCachedPlaceholder("SyntheticPlaceholder"); + data.addVoteShopPurchase("SyntheticItem"); + data.setBungeeVotePartyCurrent(3); + data.setBungeeVotePartyRequired(9); + data.setVotePartyExtraRequired(2); + data.setShopConverted(true); + data.saveDisabledReminders(new java.util.ArrayList<>()); + data.updatePlaceholders(); + var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin); + party.setTotalVotes(4); + var users = new java.util.ArrayList<>(java.util.List.of("SyntheticParticipant")); + party.setVotedUsers(users); + users.add("UnpublishedParticipant"); + assertEquals(java.util.List.of("SyntheticParticipant"), party.getVotedUsers()); + // Suppress web refresh only; exercise the real handler and its actual YAML writer. + var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) { + @Override public void refreshFromWebOnce(boolean logMore) { } + }; + services.saveToServerData(); + var world = mock(org.bukkit.World.class); + when(world.getName()).thenAnswer(call -> { + assertFalse(Thread.holdsLock(backing), "platform lookup ran under YAML ownership"); + return "SyntheticWorld"; + }); + var location = new org.bukkit.Location(world, -1.5, 64, 2.5); + data.setSign("0", location, location, "SyntheticSign", 1); + data.setSkullLocation("0", location); + assertEquals(-1, data.getData().getInt("Signs.0.X"), "retain legacy truncation"); + data.removeSign("0"); + var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(4, disk.getInt("VotingPlugin.VoteParty.Total")); + assertEquals(java.util.List.of("SyntheticParticipant"), disk.getStringList("VotingPlugin.VoteParty.Voted")); + assertEquals(1, disk.getInt("VotingPlugin.VoteShopPurchases.SyntheticItem")); + assertFalse(disk.contains("VotingPlugin.Signs.0")); + } finally { fence.retire(); } + } + } + + @Test void ordinaryMutationFamiliesWaitBeforeChangingAnOwnedSnapshot() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + data.getData(); // Section already exists: test mutation, not lazy section creation. + var party = new com.bencodez.votingplugin.specialrewards.voteparty.VotePartyState(plugin); + var services = new com.bencodez.votingplugin.servicesites.ServiceSiteHandler(plugin) { + @Override public void refreshFromWebOnce(boolean logMore) { } + }; + var operations = java.util.List.of( + () -> data.setBungeeVotePartyCurrent(3), + () -> data.addServiceSite("SyntheticSite"), + () -> data.setServiceSites(new java.util.ArrayList<>(java.util.List.of("SyntheticReplacement"))), + () -> data.addVoteShopPurchase("SyntheticItem"), + () -> data.addAutoCachedPlaceholder("SyntheticPlaceholder"), + () -> data.updatePlaceholders(), + () -> data.removeSign("0"), + () -> party.setTotalVotes(4), + () -> party.setVotedUsers(java.util.List.of("SyntheticParticipant")), + services::saveToServerData); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + for (Runnable operation : operations) { + var started = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.Future future; + synchronized (backing) { + String before = backing.getData().saveToString(); + future = worker.submit(() -> { started.countDown(); operation.run(); }); + assertTrue(started.await(5, TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.TimeoutException.class, + () -> future.get(100, TimeUnit.MILLISECONDS)); + assertEquals(before, backing.getData().saveToString(), + "ordinary mutation changed YAML before acquiring the actual snapshot owner"); + } + future.get(5, TimeUnit.SECONDS); + } + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + fence.retire(); + } + } + } + + @Test void serviceListInputsAndOutputsCannotMutatePublishedYamlByAlias() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + try { + var supplied = new java.util.ArrayList<>(java.util.List.of("SyntheticExistingSite")); + data.setServiceSites(supplied); + supplied.add("SyntheticInputAlias"); + data.getServiceSites().add("SyntheticOutputAlias"); + assertEquals(java.util.List.of("SyntheticExistingSite"), data.getServiceSitesReadOnly()); + data.saveData(); + var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals(java.util.List.of("SyntheticExistingSite"), disk.getStringList("VotingPlugin.GottenServiceSites")); + data.setServiceSites(null); + assertTrue(data.getServiceSites().isEmpty(), "retain legacy null-as-clear setter behavior"); + } finally { fence.retire(); } + } + } + + @Test void finalMutationConfirmsTheActualSDKOwnersPublication() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + checkFinalMutation(); + } + } + + private void checkFinalMutation() throws Exception { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + backing.getData().set("Adjacent.Value", "preserved"); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + try { + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + facade.mutateFinalData(data -> data.set("Final.Value", "confirmed")) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertSame(backing.getData(), facade.getData()); + var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertEquals("confirmed", disk.getString("Final.Value")); + assertEquals("preserved", disk.getString("Adjacent.Value")); + } finally { + fence.retire(); + } + } + + @Test void asynchronousOwnerRetainsAdmissionUntilItsActualCompletion() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + var backing = mock(com.bencodez.advancedcore.data.ServerData.class); + var published = new CompletableFuture(); + when(backing.mutateFinalData(any())).thenReturn(published); + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + var observation = facade.mutateFinalData(data -> {}).toCompletableFuture(); + fence.retire(); + assertFalse(observation.isDone()); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + observation.cancel(false); + assertFalse(published.isCancelled()); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + published.complete(null); + var successor = ServerDataPublicationFence.beginGeneration(target); + successor.retire(); + } + } + + @Test void failedOrCancelledPublicationCannotReleaseAdmission() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + for (boolean cancelled : new boolean[] { false, true }) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + var backing = mock(com.bencodez.advancedcore.data.ServerData.class); + var published = new CompletableFuture(); + when(backing.mutateFinalData(any())).thenReturn(published); + var target = directory.resolve(cancelled ? "Cancelled.yml" : "Failed.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + var observation = facade.mutateFinalData(data -> {}).toCompletableFuture(); + fence.retire(); + if (cancelled) published.cancel(false); + else published.completeExceptionally(new java.io.IOException("synthetic publication failure")); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> observation.get(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + } + } + } + + @Test void ordinaryMutatorsAndCheckedTransitionsUseTheSameDelegateOwner() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + try { + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + assertSame(backing, facade.publicationMonitor()); + facade.setPrevDay(12); + facade.setPrevMonth("OCTOBER"); + facade.setPrevWeekDay(41); + facade.setIgnoreTime(true); + facade.setData("Adjacent.Value", "ordinary"); + var transition = facade.beginTimeChangeTransition( + com.bencodez.advancedcore.api.time.TimeType.DAY, "synthetic-day", "13"); + assertEquals(transition, backing.getPendingTimeChangeTransition( + com.bencodez.advancedcore.api.time.TimeType.DAY)); + facade.mutateFinalData(data -> data.set("Final.Value", "confirmed")) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + facade.completeTimeChangeTransition(transition); + assertFalse(backing.isPendingTimeChangeTransition(transition)); + assertEquals(13, backing.getPrevDay()); + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("ordinary", disk.getString("Adjacent.Value")); + assertEquals("confirmed", disk.getString("Final.Value")); + assertEquals(13, disk.getInt("PrevDay")); + } finally { fence.retire(); } + } + } + + @Test void timedCheckpointCannotOvertakeTheNativeFinalSnapshot() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + var entered = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + var checkpointStarted = new java.util.concurrent.CountDownLatch(1); + var checkpointFinished = new java.util.concurrent.CountDownLatch(1); + var workers = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var publication = workers.submit(() -> facade.mutateFinalData(snapshot -> { + snapshot.set("Final.Value", "confirmed"); + entered.countDown(); + try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + }).toCompletableFuture().join()); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var checkpoint = workers.submit(() -> { + checkpointStarted.countDown(); + data.addTimeVoted(0, new com.bencodez.votingplugin.timequeue.VoteTimeQueue( + "Synthetic", "site", 123L)); + checkpointFinished.countDown(); + }); + assertTrue(checkpointStarted.await(5, TimeUnit.SECONDS)); + assertFalse(checkpointFinished.await(150, TimeUnit.MILLISECONDS)); + release.countDown(); + publication.get(5, TimeUnit.SECONDS); + checkpoint.get(5, TimeUnit.SECONDS); + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("confirmed", disk.getString("Final.Value")); + assertEquals("Synthetic", disk.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals(123L, disk.getLong("VotingPlugin.TimedVoteCache.0.Time")); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + fence.retire(); + } + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/GenerationFencedShutdownStageTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/GenerationFencedShutdownStageTest.java new file mode 100644 index 0000000000..37db2c063f --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/GenerationFencedShutdownStageTest.java @@ -0,0 +1,106 @@ +package com.bencodez.votingplugin.tests; + +import com.bencodez.votingplugin.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class GenerationFencedShutdownStageTest { + @TempDir Path directory; + + private VotingPluginMain plugin(ServerDataPublicationFence fence) throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + // Mockito bypasses constructors; initialize the real SDK lifecycle lock. + var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lock.setAccessible(true); + lock.set(plugin, new Object()); + var owner = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence"); + owner.setAccessible(true); + owner.set(plugin, fence); + return plugin; + } + + @Test void disableReservesBeforeTheSDKAdmitsItsFinalStageAndRepeatedDisableDoesNotReleaseIt() throws Exception { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var finalFiles = new CompletableFuture(); + // This fixture controls only SDK hook admission; it does not claim a + // full runtime cleanup. Adapter tests exercise the real file publisher. + try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) { + plugin.onDisable(); + fence.retainUntilTermination(plugin.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + plugin.onShutdownComplete(finalFiles); + plugin.onDisable(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + finalFiles.complete(null); + var successor = ServerDataPublicationFence.beginGeneration(target); + successor.retire(); + plugin.onDisable(); + assertThrows(IllegalStateException.class, fence::acquire); + } + } + + @Test void disableStartsTheProcessDrainBeforeSdkCleanupAndNeverRenewsIt() throws Exception { + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var plugin = plugin(fence); + var startedField = ServerDataPublicationFence.class.getDeclaredField("processShutdownStarted"); + startedField.setAccessible(true); + var started = (CompletableFuture) startedField.get(fence); + try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> { + doAnswer(call -> { + assertTrue(started.isDone(), "The consumer deadline must precede SDK cleanup"); + return null; + }).when(owner).shutdown(anyLong()); + })) { + plugin.onDisable(); + var original = started.get(5, TimeUnit.SECONDS); + plugin.onDisable(); + assertEquals(original, started.get()); + plugin.onShutdownComplete(CompletableFuture.completedFuture(null)); + } + } + + @Test void failedFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception { + checkFailure(false); + } + + @Test void cancelledFinalStageRetainsTheGenerationAfterAllCapturedExecutorsTerminate() throws Exception { + checkFailure(true); + } + + private void checkFailure(boolean cancelled) throws Exception { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var finalFiles = new CompletableFuture(); + try (var cleanup = mockConstruction(AdvancedCoreLifecycle.class)) { + plugin.onDisable(); + fence.retainUntilTermination(plugin.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + plugin.onShutdownComplete(finalFiles); + if (cancelled) finalFiles.cancel(false); + else finalFiles.completeExceptionally(new java.io.IOException("synthetic final-stage failure")); + plugin.onDisable(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + // A later duplicate hook cannot replace the failed outcome. + plugin.onShutdownComplete(CompletableFuture.completedFuture(null)); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SDKServerDataOwnerLockTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SDKServerDataOwnerLockTest.java new file mode 100644 index 0000000000..26fe8f3d91 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SDKServerDataOwnerLockTest.java @@ -0,0 +1,220 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.lang.management.ManagementFactory; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.GenerationFencedServerDataFile; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class SDKServerDataOwnerLockTest { + @TempDir Path directory; + + @Test void nativeInventoryOwnerExcludesOrdinaryPublication() throws Exception { + assertInventoryReplacementIsAtomic(false, false); + } + + @Test void nativeInventoryOwnerExcludesFinalPublication() throws Exception { + assertInventoryReplacementIsAtomic(false, true); + } + + @Test void wrappedInventoryOwnerExcludesOrdinaryPublication() throws Exception { + assertInventoryReplacementIsAtomic(true, false); + } + + @Test void wrappedInventoryOwnerExcludesFinalPublication() throws Exception { + assertInventoryReplacementIsAtomic(true, true); + } + + @Test void composedInventoryOwnerExcludesOrdinaryPublication() throws Exception { + assertInventoryReplacementIsAtomic(2, false); + } + + @Test void composedInventoryOwnerExcludesFinalPublication() throws Exception { + assertInventoryReplacementIsAtomic(2, true); + } + + @Test void composedFacadeAdvertisesTheInnermostYamlOwner() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + try { + var inner = new GenerationFencedServerDataFile(plugin, backing, fence); + var outer = new GenerationFencedServerDataFile(plugin, inner, fence); + assertSame(backing, outer.publicationMonitor(), "the delegate can itself be an adapter"); + } finally { fence.retire(); } + } + } + + private void assertInventoryReplacementIsAtomic(boolean wrapped, boolean finalPublication) throws Exception { + assertInventoryReplacementIsAtomic(wrapped ? 1 : 0, finalPublication); + } + + private void assertInventoryReplacementIsAtomic(int facadeDepth, boolean finalPublication) throws Exception { + org.bukkit.configuration.serialization.ConfigurationSerialization.registerClass(PendingItem.class); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + // Exercise the public SDK writer. Startup has no optional file yet; + // install the real owner before admitting either writer below. + var inventory = new FullInventoryHandler(plugin); + inventory.getTimer().shutdownNow(); + var cleared = new CountDownLatch(1); + var release = new CountDownLatch(1); + var armed = new AtomicBoolean(); + var observedIntermediate = new AtomicBoolean(); + var yaml = new YamlConfiguration() { + @Override public void set(String path, Object value) { + super.set(path, value); + if (path.equals("FullInventory") && value == null && armed.compareAndSet(true, false)) { + cleared.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + } + } + + @Override public String saveToString() { + String text = super.saveToString(); + if (cleared.getCount() == 0 && release.getCount() != 0 && !contains("FullInventory")) { + observedIntermediate.set(true); + } + return text; + } + }; + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void saveDataChecked() { + // New SDK owners mutate an isolated candidate, so clearing + // the live YAML is no longer a guaranteed observable seam. + // Pause the actual native publication instead. The legacy + // clear hook above still exercises its temporary-live gap. + if (armed.compareAndSet(true, false)) { + assertTrue(Thread.holdsLock(this), + "native snapshot publication must own the backing monitor"); + cleared.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + } + super.saveDataChecked(); + } + }; + backing.setData(yaml); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + com.bencodez.advancedcore.data.ServerData composed = backing; + for (int depth = 0; depth < facadeDepth; depth++) { + composed = new GenerationFencedServerDataFile(plugin, composed, fence); + } + var exposed = composed; + when(plugin.getServerDataFile()).thenReturn(exposed); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + data.getData(); + UUID previous = new UUID(0, 2); + inventory.getItems().put(previous, new ArrayList<>(java.util.List.of(new PendingItem(1)))); + assertTrue(inventory.saveDurably()); + var initialDisk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertInstanceOf(PendingItem.class, initialDisk.get("FullInventory." + previous + ".Items.0")); + assertEquals(1, initialDisk.getItemStack("FullInventory." + previous + ".Items.0").getAmount()); + inventory.getItems().clear(); + UUID replacement = new UUID(0, 1); + inventory.getItems().put(replacement, new ArrayList<>(java.util.List.of(new PendingItem(2)))); + var workers = Executors.newFixedThreadPool(2); + var inventoryThread = new AtomicReference(); + var competingThread = new AtomicReference(); + var competingStarted = new CountDownLatch(1); + try { + armed.set(true); + var saving = workers.submit(() -> { + inventoryThread.set(Thread.currentThread()); + return inventory.saveDurably(); + }); + assertTrue(cleared.await(5, TimeUnit.SECONDS)); + var competing = workers.submit(() -> { + competingThread.set(Thread.currentThread()); + competingStarted.countDown(); + if (finalPublication) { + exposed.mutateFinalData(snapshot -> { + if (release.getCount() != 0 && !snapshot.contains("FullInventory")) { + observedIntermediate.set(true); + } + snapshot.set("SyntheticFinal.Value", "confirmed"); + }).toCompletableFuture().join(); + } else { + data.setInt("VoteParty.Total", 7); + } + }); + assertTrue(competingStarted.await(5, TimeUnit.SECONDS)); + var threads = ManagementFactory.getThreadMXBean(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + boolean blockedByOwner = false; + while (!observedIntermediate.get() && !competing.isDone() && System.nanoTime() < deadline) { + var info = threads.getThreadInfo(competingThread.get().threadId()); + if (info != null && info.getThreadState() == Thread.State.BLOCKED + && info.getLockOwnerId() == inventoryThread.get().threadId() + && info.getLockInfo() != null + && info.getLockInfo().getIdentityHashCode() == System.identityHashCode(backing)) { + blockedByOwner = true; + break; + } + Thread.yield(); + } + assertTrue(blockedByOwner || observedIntermediate.get() || competing.isDone(), + "competing writer never reached the ownership or serialization boundary"); + // Confirm the defective publication before releasing the SDK + // replacement, rather than racing two unsynchronized YAML edits. + if (observedIntermediate.get()) competing.get(5, TimeUnit.SECONDS); + boolean publishedIntermediate = observedIntermediate.get(); + release.countDown(); + assertTrue(saving.get(5, TimeUnit.SECONDS)); + competing.get(5, TimeUnit.SECONDS); + var disk = YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()); + assertTrue(disk.contains("FullInventory." + replacement + ".Time")); + assertEquals(2, disk.getItemStack("FullInventory." + replacement + ".Items.0").getAmount()); + assertFalse(disk.contains("FullInventory." + previous)); + if (finalPublication) assertEquals("confirmed", disk.getString("SyntheticFinal.Value")); + else assertEquals(7, disk.getInt("VotingPlugin.VoteParty.Total")); + assertFalse(publishedIntermediate, + "a successful publication serialized the SDK's temporarily cleared recovery section"); + assertTrue(blockedByOwner, "the competing writer must share the actual SDK transaction monitor"); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + inventory.getTimer().shutdownNow(); + assertTrue(inventory.getTimer().awaitTermination(5, TimeUnit.SECONDS)); + fence.retire(); + } + } finally { + org.bukkit.configuration.serialization.ConfigurationSerialization.unregisterClass(PendingItem.class); + } + } + + /** Headless item serialization keeps the actual SDK writer and recovery YAML in the test. */ + @org.bukkit.configuration.serialization.SerializableAs("VPServerDataMonitorTestItem") + public static final class PendingItem extends org.bukkit.inventory.ItemStack { + public PendingItem(int amount) { super(org.bukkit.Material.STONE, amount); } + @Override public java.util.Map serialize() { return java.util.Map.of("amount", getAmount()); } + public static PendingItem deserialize(java.util.Map values) { + return new PendingItem(((Number) values.get("amount")).intValue()); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SDKServerDataPublicationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SDKServerDataPublicationTest.java new file mode 100644 index 0000000000..3e540e093a --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SDKServerDataPublicationTest.java @@ -0,0 +1,427 @@ +package com.bencodez.votingplugin.tests; + +import com.bencodez.votingplugin.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.PluginDescriptionFile; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.advancedcore.data.ServerData; +import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class SDKServerDataPublicationTest { + @TempDir Path directory; + private static final class StartupReached extends IllegalStateException { } + + private VotingPluginMain plugin() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + // Mockito bypasses constructors; use the SDK's actual lifecycle lock. + var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lock.setAccessible(true); + lock.set(plugin, new Object()); + doNothing().when(plugin).registerEvents(any()); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + doReturn("VotingPlugin").when(plugin).getName(); + doReturn(new PluginDescriptionFile("VotingPlugin", "1", "synthetic.Main")).when(plugin).getDescription(); + doThrow(new StartupReached()).when(plugin).onPreLoad(); + return plugin; + } + + private static org.mockito.MockedConstruction completedCleanup() { + // Control only unrelated platform cleanup. Real SDK startup admission, + // file publishers and VotingPlugin generation ownership stay in use. + // These fixtures do not claim full platform shutdown validation. + return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> { + var plugin = (VotingPluginMain) context.arguments().get(0); + doAnswer(call -> { + var completed = CompletableFuture.completedFuture(null); + plugin.onShutdownComplete(completed); + plugin.releaseServerDataOwnershipAfter(completed); + return null; + }).when(cleanup).shutdown(anyLong()); + }); + } + + private static void awaitCapturedRetirement(VotingPluginMain plugin) throws Exception { + plugin.getServerDataPublicationFence().retainUntilTermination(plugin.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + } + + private static void installSDKFile(VotingPluginMain plugin, ServerData file) throws Exception { + var field = AdvancedCorePlugin.class.getDeclaredField("serverDataFile"); + field.setAccessible(true); + field.set(plugin, file); + } + + private static ScheduledExecutorService terminatedExecutor() { + var executor = mock(ScheduledExecutorService.class); + when(executor.isTerminated()).thenReturn(true); + return executor; + } + + @Test void rejectedSDKAdmissionRetiresOnlyItsUnusedPluginGenerationAndCanRetryAfterRelease() throws Exception { + var plugin = plugin(); + var delayedRelease = new CompletableFuture(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var constructors = mockConstruction(BukkitScheduler.class); + var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> { + doAnswer(call -> { + plugin.onShutdownComplete(CompletableFuture.completedFuture(null)); + plugin.releaseServerDataOwnershipAfter(delayedRelease); + return null; + }).when(owner).shutdown(anyLong()); + })) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, plugin::onEnable); + awaitCapturedRetirement(plugin); + for (int attempt = 0; attempt < 2; attempt++) { + var failure = assertThrows(IllegalStateException.class, plugin::onEnable); + assertEquals("Previous ServerData shutdown has not finished", failure.getMessage()); + plugin.onDisable(); + } + delayedRelease.complete(null); + assertThrows(StartupReached.class, plugin::onEnable); + awaitCapturedRetirement(plugin); + } finally { + delayedRelease.complete(null); + plugin.onShutdownComplete(CompletableFuture.completedFuture(null)); + if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire(); + } + } + + @Test void admittedSDKInitializationFailureStillRetainsItsPendingFinalPublication() throws Exception { + var plugin = plugin(); + var pendingPublication = new CompletableFuture(); + var initializationFailure = new IllegalStateException("synthetic executor initialization failure"); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var constructors = mockConstruction(BukkitScheduler.class); + var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> { + doAnswer(call -> { + plugin.onShutdownComplete(pendingPublication); + plugin.releaseServerDataOwnershipAfter(pendingPublication); + return null; + }).when(owner).shutdown(anyLong()); + })) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())) + .thenThrow(initializationFailure).thenReturn(runtime); + assertSame(initializationFailure, assertThrows(IllegalStateException.class, plugin::onEnable)); + verify(plugin, never()).onPreLoad(); + assertFalse(pendingPublication.isDone()); + assertThrows(IllegalStateException.class, plugin::onEnable); + pendingPublication.complete(null); + awaitCapturedRetirement(plugin); + assertThrows(StartupReached.class, plugin::onEnable); + awaitCapturedRetirement(plugin); + } finally { + pendingPublication.complete(null); + if (plugin.getServerDataPublicationFence() != null) plugin.getServerDataPublicationFence().retire(); + } + } + + @Test void actualSDKVersionCallbackRetainsOwnershipOutsideCapturedExecutors() throws Exception { + var old = plugin(); var next = plugin(); + Path target = directory.resolve("ServerData.yml"); + var yaml = spy(new YamlConfiguration()); + var scheduler = mock(BukkitScheduler.class); + var callback = new AtomicReference(); + doAnswer(call -> { callback.set(call.getArgument(1)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(scheduler, + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService externalWriter = Executors.newSingleThreadExecutor(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var cleanup = completedCleanup(); + var constructors = mockConstruction(BukkitScheduler.class)) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + var writingReference = new AtomicReference>(); + doAnswer(startup -> { + var backing = new ServerData(old); + backing.setData(yaml); backing.setPluginVersion(old); + installSDKFile(old, backing); + assertSame(yaml, old.getServerDataFile().getData()); + old.setLoadServerData(true); + old.checkPluginUpdate(); + assertNotNull(callback.get()); + doAnswer(call -> { + entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS)); + return call.callRealMethod(); + }).when(yaml).save(target.toFile()); + // The SDK now cleans up a failed startup itself. Start the + // real captured writer before that cleanup retires startup. + writingReference.set(externalWriter.submit(callback.get())); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + throw new StartupReached(); + }).when(old).onPreLoad(); + assertThrows(StartupReached.class, old::onEnable); + Future writing = writingReference.get(); + assertNotNull(writing); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + old.onDisable(); + old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached, + "SDK publication must retain ownership even when every captured executor is terminated"); + verify(next, never()).onPreLoad(); + release.countDown(); writing.get(5, TimeUnit.SECONDS); + var successor = YamlConfiguration.loadConfiguration(target.toFile()); + successor.set("Adjacent.Value", "successor"); successor.save(target.toFile()); + doAnswer(startup -> { + // Run while the SDK's replacement plugin identity is live, + // before its sentinel failure triggers automatic cleanup. + assertThrows(IllegalStateException.class, callback.get()::run, + "an old callback that starts after replacement cannot publish"); + assertThrows(IllegalStateException.class, () -> old.getServerDataFile().setPrevDay(12)); + assertEquals("successor", YamlConfiguration.loadConfiguration(target.toFile()) + .getString("Adjacent.Value")); + throw new StartupReached(); + }).when(next).onPreLoad(); + assertThrows(StartupReached.class, next::onEnable); + awaitCapturedRetirement(next); + next.getServerDataPublicationFence().retire(); + } finally { + release.countDown(); externalWriter.shutdownNow(); + assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS)); + if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire(); + } + } + + @Test void inheritedFileOperationsKeepTheSDKConfigurationAuthoritative() throws Exception { + var plugin = plugin(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var constructors = mockConstruction(BukkitScheduler.class); + var completedCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, plugin::onEnable); + var backing = new ServerData(plugin); backing.setData(new YamlConfiguration()); + installSDKFile(plugin, backing); + var facade = plugin.getServerDataFile(); + assertSame(backing.getData(), facade.getData()); + facade.setIgnoreCase(true); + assertTrue(backing.isIgnoreCase()); assertTrue(facade.isIgnoreCase()); + assertSame(backing.getData(), facade.getData()); + facade.setValue("Sample.Value", "kept"); + facade.createSection("Sample.Section"); + var merged = new YamlConfiguration(); merged.set("Adjacent.Value", "merged"); + facade.setData(merged); facade.saveData(); + facade.reloadData(); + assertSame(backing.getData(), facade.getData()); + assertEquals("kept", facade.getData().getString("Sample.Value")); + assertEquals("merged", facade.getData().getString("Adjacent.Value")); + assertNotNull(facade.getData().getConfigurationSection("Sample.Section")); + assertEquals(backing.isJustCreated(), facade.isJustCreated()); + assertEquals(backing.isFailedToRead(), facade.isFailedToRead()); + assertSame(backing.getPlugin(), facade.getPlugin()); + assertEquals(backing.getdFile(), facade.getdFile()); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + } + } + + @Test void oldVotingPluginWrapperDoesNotBorrowTheSamePluginObjectsReplacementFileOrFence() throws Exception { + var plugin = plugin(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var constructors = mockConstruction(BukkitScheduler.class); + var completedCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, plugin::onEnable); + var first = new ServerData(plugin); first.setData(new YamlConfiguration()); + installSDKFile(plugin, first); + var stale = new com.bencodez.votingplugin.data.ServerData(plugin); + stale.getData().set("Adjacent.Value", "first"); stale.saveData(); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + assertThrows(StartupReached.class, plugin::onEnable); + var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration()); + installSDKFile(plugin, replacement); + var current = new com.bencodez.votingplugin.data.ServerData(plugin); + current.getData().set("Adjacent.Value", "successor"); current.saveData(); + assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0, + new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L))); + assertEquals("successor", current.getData().getString("Adjacent.Value")); + assertNull(current.getData().get("TimedVoteCache")); + assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()) + .getString("VotingPlugin.Adjacent.Value")); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + } + } + + @Test void firstUseOfAnOldWrapperAfterReenableDoesNotMutateTheReplacementFile() throws Exception { + var plugin = plugin(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var constructors = mockConstruction(BukkitScheduler.class); + var completedCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, plugin::onEnable); + var first = new ServerData(plugin); first.setData(new YamlConfiguration()); + installSDKFile(plugin, first); + var stale = new com.bencodez.votingplugin.data.ServerData(plugin); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + assertThrows(StartupReached.class, plugin::onEnable); + var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration()); + installSDKFile(plugin, replacement); + var current = new com.bencodez.votingplugin.data.ServerData(plugin); + current.getData().set("Adjacent.Value", "successor"); current.saveData(); + assertThrows(IllegalStateException.class, () -> stale.addTimeVoted(0, + new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L))); + assertEquals("successor", current.getData().getString("Adjacent.Value")); + assertNull(current.getData().get("TimedVoteCache")); + assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()) + .getString("VotingPlugin.Adjacent.Value")); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + } + } + + @Test void failedReenableBeforeFileReplacementCanRecoverAfterNativeTimestampCleanup() throws Exception { + var plugin = plugin(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + var observed = new AtomicReference>(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var constructors = mockConstruction(BukkitScheduler.class); + var cleanup = mockConstruction(AdvancedCoreLifecycle.class, (owner, context) -> { + doAnswer(call -> { + // Exercise the actual SDK timestamp submission, while + // unrelated platform teardown remains controlled. + var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(plugin); + var method = platform.getClass().getDeclaredMethod("submitTimestampPersistence", Executor.class); + method.setAccessible(true); + var workerField = platform.getClass().getDeclaredField("persistenceWorker"); + workerField.setAccessible(true); + var worker = (ExecutorService) workerField.get(platform); + CompletionStage stage; + try { + @SuppressWarnings("unchecked") + var submitted = (CompletionStage) method.invoke(platform, worker); + stage = submitted; + } finally { + worker.shutdown(); + } + observed.set(stage); + plugin.onShutdownComplete(stage); + plugin.releaseServerDataOwnershipAfter(stage); + return null; + }).when(owner).shutdown(anyLong()); + })) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + var initialFailure = assertThrows(StartupReached.class, plugin::onEnable); + assertEquals(0, initialFailure.getSuppressed().length, + "native timestamp cleanup must actually execute without hidden fixture failures"); + assertNotNull(observed.get(), "capture the actual SDK final-publication stage"); + observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS); + awaitCapturedRetirement(plugin); + var backing = new ServerData(plugin); backing.setData(new YamlConfiguration()); + installSDKFile(plugin, backing); + var retiredFacade = plugin.getServerDataFile(); + assertNotNull(retiredFacade); + assertThrows(StartupReached.class, plugin::onEnable); + observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS); + awaitCapturedRetirement(plugin); + assertThrows(IllegalStateException.class, () -> retiredFacade.setPrevDay(12)); + assertThrows(StartupReached.class, plugin::onEnable, + "a failed pre-load must not permanently reserve either generation owner"); + observed.get().toCompletableFuture().get(5, TimeUnit.SECONDS); + awaitCapturedRetirement(plugin); + } + } + + @Test void fileReadBetweenReenableAndSDKReplacementWithholdsPredecessorAndKeepsCapturedFence() throws Exception { + var plugin = plugin(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var constructors = mockConstruction(BukkitScheduler.class); + var completedCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, plugin::onEnable); + var backing = new ServerData(plugin); backing.setData(new YamlConfiguration()); + installSDKFile(plugin, backing); + backing.setPrevDay(1); + var predecessor = plugin.getServerDataFile(); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + var intervening = new AtomicReference(); + doAnswer(call -> { + intervening.set(plugin.getServerDataFile()); + throw new StartupReached(); + }).when(plugin).onPreLoad(); + assertThrows(StartupReached.class, plugin::onEnable); + var replacement = new ServerData(plugin); replacement.setData(new YamlConfiguration()); + installSDKFile(plugin, replacement); + plugin.getServerDataFile().setValue("Adjacent.Value", "successor"); + assertNull(intervening.get(), "pre-load must not expose a predecessor file to native final publication"); + assertThrows(IllegalStateException.class, () -> predecessor.setPrevDay(12)); + assertEquals("successor", YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()) + .getString("Adjacent.Value")); + awaitCapturedRetirement(plugin); + plugin.getServerDataPublicationFence().retire(); + } + } + + @Test void sdkDurableTransitionPublicationAlsoOwnsTheGenerationUntilItReturns() throws Exception { + var old = plugin(); var next = plugin(); + Path target = directory.resolve("ServerData.yml"); + var yaml = spy(new YamlConfiguration()); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService externalWriter = Executors.newSingleThreadExecutor(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var constructors = mockConstruction(BukkitScheduler.class); + var completedCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, old::onEnable); + var backing = new ServerData(old); backing.setData(yaml); installSDKFile(old, backing); + doAnswer(call -> { + entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS)); + return call.callRealMethod(); + }).when(yaml).saveToString(); + var file = old.getServerDataFile(); + Future writing = externalWriter.submit(() -> file.beginTimeChangeTransition(TimeType.DAY, "synthetic-period", "12")); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + old.getServerDataPublicationFence().retire(); + assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached); + release.countDown(); var transition = writing.get(5, TimeUnit.SECONDS); + var restored = new ServerData(old); restored.setData(YamlConfiguration.loadConfiguration(target.toFile())); + assertEquals(transition, restored.getPendingTimeChangeTransition(TimeType.DAY)); + assertThrows(StartupReached.class, next::onEnable); + assertThrows(IllegalStateException.class, () -> file.completeTimeChangeTransition(transition)); + next.getServerDataPublicationFence().retire(); + } finally { + release.countDown(); externalWriter.shutdownNow(); + assertTrue(externalWriter.awaitTermination(5, TimeUnit.SECONDS)); + if (old.getServerDataPublicationFence() != null) old.getServerDataPublicationFence().retire(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServerDataGenerationShutdownTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServerDataGenerationShutdownTest.java new file mode 100644 index 0000000000..d7ad4ca5c8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServerDataGenerationShutdownTest.java @@ -0,0 +1,492 @@ +package com.bencodez.votingplugin.tests; + +import com.bencodez.votingplugin.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.concurrent.*; +import java.util.logging.Logger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; + +class ServerDataGenerationShutdownTest { + @TempDir Path directory; + private static final class StartupReached extends IllegalStateException { } + + private static void initializePlugin(VotingPluginMain plugin) throws Exception { + // Constructor-bypassing Mockito objects need the real SDK lifecycle lock. + var lock = com.bencodez.advancedcore.AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lock.setAccessible(true); + lock.set(plugin, new Object()); + doNothing().when(plugin).registerEvents(any()); + } + + private static org.mockito.MockedConstruction completedCleanup() { + // Only platform cleanup is controlled. Actual plugin admission, timed + // publication and generation/writer reservations remain under test. + return mockConstruction(AdvancedCoreLifecycle.class, (cleanup, context) -> { + var plugin = (VotingPluginMain) context.arguments().get(0); + doAnswer(call -> { + for (var writer : plugin.getServerDataWriterExecutors()) + if (writer != null) writer.shutdown(); + var completed = CompletableFuture.completedFuture(null); + plugin.onShutdownComplete(completed); + plugin.releaseServerDataOwnershipAfter(completed); + return null; + }).when(cleanup).shutdown(anyLong()); + }); + } + + private static ScheduledExecutorService terminatedExecutor() { + var executor = mock(ScheduledExecutorService.class); + when(executor.isTerminated()).thenReturn(true); + return executor; + } + + @Test void failedShutdownSuffixKeepsItsGenerationUntilExplicitRetryPersistsEveryVote() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + initializePlugin(plugin); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + Path target = directory.resolve("ServerData.yml"); + var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target); + var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence"); + field.setAccessible(true); field.set(plugin, fence); + var yaml = spy(new YamlConfiguration()); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile()); + doReturn(file).when(plugin).getServerDataFile(); + doReturn(new ServerData(plugin)).when(plugin).getServerData(); + doReturn(new ScheduledExecutorService[0]).when(plugin).getServerDataWriterExecutors(); + var producer = new ScheduledThreadPoolExecutor(1); + producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + doReturn(producer).when(plugin).getVoteTimer(); + try { + var handler = new TimeQueueHandler(plugin); + handler.addVote("First", "site"); + handler.addVote("Second", "site"); + handler.addVote("Third", "site"); + producer.shutdown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS)); + var writes = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + int attempt = writes.incrementAndGet(); + if (attempt == 2 || attempt == 3) throw new java.io.IOException("synthetic suffix failure"); + return call.callRealMethod(); + }).when(yaml).save(target.toFile()); + assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown); + fence.retire(); + assertEquals(2, handler.getTimeChangeQueue().size()); + assertThrows(IllegalStateException.class, () -> + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target)); + assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown); + assertEquals(2, handler.getTimeChangeQueue().size()); + assertThrows(IllegalStateException.class, () -> + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target)); + handler.saveForShutdown(); + assertTrue(handler.getTimeChangeQueue().isEmpty()); + var recovered = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("First", recovered.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals("Second", recovered.getString("VotingPlugin.TimedVoteCache.1.Name")); + assertEquals("Third", recovered.getString("VotingPlugin.TimedVoteCache.2.Name")); + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire(); + } finally { + producer.shutdownNow(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void rejectedShutdownWorkerRetryRetainsTheOriginalProducerAfterUnloadClearsItsField() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + initializePlugin(plugin); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + Path target = directory.resolve("ServerData.yml"); + var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target); + var field = VotingPluginMain.class.getDeclaredField("serverDataPublicationFence"); + field.setAccessible(true); field.set(plugin, fence); + var yaml = spy(new YamlConfiguration()); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile()); + doReturn(file).when(plugin).getServerDataFile(); + doReturn(new ServerData(plugin)).when(plugin).getServerData(); + var releaseCore = new CountDownLatch(1); + var coreWriter = mock(ScheduledExecutorService.class); + when(coreWriter.isTerminated()).thenAnswer(call -> releaseCore.getCount() == 0); + when(coreWriter.awaitTermination(anyLong(), any())).thenAnswer(call -> + releaseCore.await(call.getArgument(0), call.getArgument(1))); + var capturedWriters = new ScheduledExecutorService[] { coreWriter }; + doReturn(capturedWriters).when(plugin).getServerDataWriterExecutors(); + var producer = new ScheduledThreadPoolExecutor(1); + producer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + doReturn(producer).when(plugin).getVoteTimer(); + var failWrites = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { + if (failWrites.get()) throw new java.io.IOException("synthetic late suffix failure"); + return call.callRealMethod(); + }).when(yaml).save(target.toFile()); + var handler = new TimeQueueHandler(plugin) { + private boolean reject = true; + @Override protected void startShutdownWorker(Runnable task) { + if (reject) { reject = false; throw new RejectedExecutionException("synthetic admission failure"); } + super.startShutdownWorker(task); + } + }; + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var lateFailure = new java.util.concurrent.atomic.AtomicReference(); + try { + producer.submit(() -> { + entered.countDown(); + boolean interrupted = false; + while (true) { + try { if (release.await(10, TimeUnit.SECONDS)) break; throw new AssertionError("release timeout"); } + catch (InterruptedException ignored) { interrupted = true; } + } + try { handler.addVote("Late", "site"); } + catch (Throwable failure) { lateFailure.set(failure); } + finally { if (interrupted) Thread.currentThread().interrupt(); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + producer.shutdownNow(); + assertThrows(RejectedExecutionException.class, handler::saveForShutdown); + fence.retire(); + // The actual unload path clears this accessor even if shutdownNow did not terminate work. + doReturn(null).when(plugin).getVoteTimer(); + capturedWriters[0] = null; + handler.saveForShutdown(); + assertThrows(IllegalStateException.class, () -> + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire()); + release.countDown(); assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS)); + assertInstanceOf(java.io.UncheckedIOException.class, lateFailure.get()); + // The original core writer is also retained, independent of the caller's mutable array. + assertThrows(IllegalStateException.class, () -> + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire()); + releaseCore.countDown(); + assertThrows(java.io.UncheckedIOException.class, handler::saveForShutdown); + assertEquals(1, handler.getTimeChangeQueue().size()); + assertThrows(IllegalStateException.class, () -> + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target)); + failWrites.set(false); handler.saveForShutdown(); + assertTrue(handler.getTimeChangeQueue().isEmpty()); + assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile()) + .getString("VotingPlugin.TimedVoteCache.0.Name")); + com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration(target).retire(); + } finally { + release.countDown(); releaseCore.countDown(); producer.shutdownNow(); + assertTrue(producer.awaitTermination(5, TimeUnit.SECONDS)); + failWrites.set(false); + // Do not start a new retry from cleanup or hide the failed ownership assertion. + fence.retire(); + } + } + + @Test void failedStartupWithoutATimedQueueKeepsCoreWritesFencedThroughActualDisable() throws Exception { + VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + for (var plugin : java.util.List.of(old, next)) { + initializePlugin(plugin); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + doThrow(new StartupReached()).when(plugin).onPreLoad(); + } + ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + writer, terminatedExecutor(), terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var controlledCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + var runningReference = new java.util.concurrent.atomic.AtomicReference>(); + doAnswer(startup -> { + runningReference.set(writer.submit(() -> { + entered.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + throw new StartupReached(); + }).when(old).onPreLoad(); + assertThrows(StartupReached.class, old::onEnable); + assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached); + Future running = runningReference.get(); + assertNotNull(running); + old.onDisable(); + assertFalse(assertThrows(IllegalStateException.class, next::onEnable) instanceof StartupReached); + var completion = old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors()); + assertFalse(completion.toCompletableFuture().isDone()); + release.countDown(); running.get(5, TimeUnit.SECONDS); + assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); + completion.toCompletableFuture().get(5, TimeUnit.SECONDS); + assertThrows(StartupReached.class, next::onEnable); + next.getServerDataPublicationFence().retire(); + } finally { release.countDown(); writer.shutdownNow(); assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); } + } + + @Test void heartbeatWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("heartbeat"); } + @Test void loginWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("login"); } + @Test void inventoryWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("inventory"); } + @Test void timeCheckerWriterRemainsFencedAfterEmptyTimedSnapshot() throws Exception { checkCoreWriter("timechecker"); } + + private void checkCoreWriter(String writerKind) throws Exception { + Path target = directory.resolve("ServerData.yml"); + VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + for (var plugin : java.util.List.of(old, next)) { + initializePlugin(plugin); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + doThrow(new StartupReached()).when(plugin).onPreLoad(); + } + var yaml = spy(new YamlConfiguration()); + yaml.set("Adjacent.Value", "old"); + yaml.save(target.toFile()); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); + when(file.getdFile()).thenReturn(target.toFile()); + doReturn(file).when(old).getServerDataFile(); + doReturn(new ServerData(old)).when(old).getServerData(); + doReturn(terminatedExecutor()).when(old).getVoteTimer(); + ScheduledThreadPoolExecutor writer = new ScheduledThreadPoolExecutor(1); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var controlledCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, old::onEnable); + old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + old.getServerDataPublicationFence().retire(); + switch (writerKind) { + case "heartbeat" -> doReturn(writer).when(old).getTimer(); + case "login" -> doReturn(writer).when(old).getLoginTimer(); + case "inventory" -> doReturn(writer).when(old).getInventoryTimer(); + case "timechecker" -> { + var checker = mock(com.bencodez.advancedcore.api.time.TimeChecker.class); + doReturn(checker).when(old).getTimeChecker(); + when(checker.getTimer()).thenReturn(writer); + } + default -> throw new AssertionError(writerKind); + } + TimeQueueHandler handler = new TimeQueueHandler(old); + doAnswer(call -> { + entered.countDown(); assertTrue(release.await(10, TimeUnit.SECONDS)); + return call.callRealMethod(); + }).when(yaml).save(target.toFile()); + Future writing = writer.submit(() -> { + try { file.getData().save(file.getdFile()); } + catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS); + IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable); + assertFalse(blocked instanceof StartupReached); + verify(next, never()).onPreLoad(); + writer.shutdown(); release.countDown(); writing.get(5, TimeUnit.SECONDS); + assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); + handler.saveForShutdown(); + assertThrows(StartupReached.class, next::onEnable); + } finally { + release.countDown(); writer.shutdownNow(); caller.shutdownNow(); + assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void initialShutdownWriteFailureStillRetainsTheRunningProducerReservation() throws Exception { + Path target = directory.resolve("ServerData.yml"); + VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + for (var plugin : java.util.List.of(old, next)) { + initializePlugin(plugin); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + doThrow(new StartupReached()).when(plugin).onPreLoad(); + } + var yaml = spy(new YamlConfiguration()); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); when(file.getdFile()).thenReturn(target.toFile()); + doReturn(file).when(old).getServerDataFile(); + doReturn(new ServerData(old)).when(old).getServerData(); + ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1); + producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + doReturn(producers).when(old).getVoteTimer(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var controlledCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, old::onEnable); + old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + old.getServerDataPublicationFence().retire(); + TimeQueueHandler handler = new TimeQueueHandler(old); + handler.addVote("First", "site"); + var late = producers.submit(() -> { + entered.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + handler.addVote("Late", "site"); + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); producers.shutdown(); + doThrow(new java.io.IOException("synthetic initial write failure")).doCallRealMethod() + .when(yaml).save(target.toFile()); + caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS); + IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable); + assertFalse(blocked instanceof StartupReached); + release.countDown(); late.get(5, TimeUnit.SECONDS); + assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS)); + try { handler.saveForShutdown(); } catch (java.io.UncheckedIOException observedInitialFailure) { } + handler.saveForShutdown(); + assertThrows(StartupReached.class, next::onEnable); + assertEquals("First", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile()).getString("VotingPlugin.TimedVoteCache.1.Name")); + } finally { + release.countDown(); producers.shutdownNow(); caller.shutdownNow(); + assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void emptyShutdownSnapshotStillOwnsLateProducersUntilTheyFinish() throws Exception { + Path target = directory.resolve("ServerData.yml"); + VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + for (var plugin : java.util.List.of(old, next)) { + initializePlugin(plugin); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + doThrow(new StartupReached()).when(plugin).onPreLoad(); + } + var yaml = new YamlConfiguration(); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); + when(file.getdFile()).thenReturn(target.toFile()); + doReturn(file).when(old).getServerDataFile(); + var data = new ServerData(old); + doReturn(data).when(old).getServerData(); + ScheduledThreadPoolExecutor producers = new ScheduledThreadPoolExecutor(1); + producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + doReturn(producers).when(old).getVoteTimer(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var controlledCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, old::onEnable); + old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + old.getServerDataPublicationFence().retire(); + TimeQueueHandler handler = new TimeQueueHandler(old); + Future late = producers.submit(() -> { + entered.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + handler.addVote("Late", "site"); + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + producers.shutdown(); + caller.submit(handler::saveForShutdown).get(2, TimeUnit.SECONDS); + IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable); + assertFalse(blocked instanceof StartupReached); + verify(next, never()).onPreLoad(); + release.countDown(); + late.get(5, TimeUnit.SECONDS); + assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS)); + handler.saveForShutdown(); + assertThrows(StartupReached.class, next::onEnable); + assertEquals("Late", YamlConfiguration.loadConfiguration(target.toFile()) + .getString("VotingPlugin.TimedVoteCache.0.Name")); + } finally { + release.countDown(); producers.shutdownNow(); caller.shutdownNow(); + assertTrue(producers.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void replacementCannotReadOrWriteServerDataWhilePreviousShutdownPublicationIsBlocked() throws Exception { + Path target = directory.resolve("ServerData.yml"); + VotingPluginMain old = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + VotingPluginMain next = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + for (VotingPluginMain plugin : java.util.List.of(old, next)) { + initializePlugin(plugin); + doReturn(directory.toFile()).when(plugin).getDataFolder(); + doReturn(mock(Logger.class)).when(plugin).getLogger(); + } + YamlConfiguration yaml = spy(new YamlConfiguration()); + yaml.set("Adjacent.Value", "old"); + yaml.save(target.toFile()); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); + when(file.getdFile()).thenReturn(target.toFile()); + doReturn(file).when(old).getServerDataFile(); + ServerData data = new ServerData(old); + doReturn(data).when(old).getServerData(); + doReturn(terminatedExecutor()).when(old).getVoteTimer(); + doThrow(new StartupReached()).when(old).onPreLoad(); + doAnswer(call -> { + YamlConfiguration replacement = YamlConfiguration.loadConfiguration(target.toFile()); + replacement.set("Adjacent.Value", "replacement"); + replacement.save(target.toFile()); + throw new StartupReached(); + }).when(next).onPreLoad(); + var runtime = new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), + terminatedExecutor(), terminatedExecutor(), + terminatedExecutor()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try (var lifecycle = mockStatic(AdvancedCoreLifecycle.class); + var commands = mockStatic(com.bencodez.advancedcore.command.CommandLoader.class); + var controlledCleanup = completedCleanup()) { + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any())).thenReturn(runtime); + assertThrows(StartupReached.class, old::onEnable); + old.getServerDataPublicationFence().retainUntilTermination(old.getServerDataWriterExecutors()) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + old.getServerDataPublicationFence().retire(); + TimeQueueHandler handler = new TimeQueueHandler(old); + handler.addVote("First", "site"); + doAnswer(call -> { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + return call.callRealMethod(); + }).when(yaml).save(target.toFile()); + Future shutdown = caller.submit(handler::saveForShutdown); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + shutdown.get(2, TimeUnit.SECONDS); + IllegalStateException blocked = assertThrows(IllegalStateException.class, next::onEnable); + assertFalse(blocked instanceof StartupReached, "replacement must be fenced before any startup read/write"); + verify(next, never()).onPreLoad(); + release.countDown(); + handler.saveForShutdown(); + assertThrows(StartupReached.class, next::onEnable); + assertThrows(IllegalStateException.class, () -> data.addTimeVoted(1, + new com.bencodez.votingplugin.timequeue.VoteTimeQueue("Stale", "site", 123L))); + assertEquals("replacement", YamlConfiguration.loadConfiguration(target.toFile()).getString("Adjacent.Value")); + } finally { + release.countDown(); + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServerDataProcessExitDrainTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServerDataProcessExitDrainTest.java new file mode 100644 index 0000000000..8104b84820 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/ServerDataProcessExitDrainTest.java @@ -0,0 +1,249 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.GenerationFencedServerDataFile; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.signs.SignHandler; +import com.bencodez.votingplugin.signs.Signs; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class ServerDataProcessExitDrainTest { + @TempDir Path directory; + + /** Observe entry to the real bounded wait without replacing its completion semantics. */ + private static final class ObservedCompletion extends CompletableFuture { + final CountDownLatch entered = new CountDownLatch(1); + volatile long nanos; + @Override public Void get(long timeout, TimeUnit unit) + throws InterruptedException, ExecutionException, TimeoutException { + nanos = unit.toNanos(timeout); + entered.countDown(); + return super.get(timeout, unit); + } + } + + private static Object field(Object owner, String name) throws Exception { + var f = owner.getClass().getDeclaredField(name); + f.setAccessible(true); + return f.get(owner); + } + + private static ObservedCompletion observe(ServerDataPublicationFence fence) throws Exception { + var completion = new ObservedCompletion(); + var f = ServerDataPublicationFence.class.getDeclaredField("processExitCompletion"); + f.setAccessible(true); + f.set(fence, completion); + return completion; + } + + @Test void outstandingLeaseKeepsTheActualProcessHookWaitingUntilRetirementDrains() throws Exception { + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var completion = observe(fence); + var lease = fence.acquire(); + fence.retainForProcessExit(); + var hook = (Thread) field(fence, "processHook"); + fence.retainForProcessExit(); + assertSame(hook, field(fence, "processHook"), "Repeated enrollment must not register another hook"); + fence.beginShutdown(System.nanoTime()); + fence.retire(); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var exit = caller.submit(hook::run); + assertTrue(completion.entered.await(5, TimeUnit.SECONDS)); + assertFalse(completion.isDone()); + assertFalse(exit.isDone(), "A daemon reservation must still be represented at process exit"); + lease.close(); + exit.get(5, TimeUnit.SECONDS); + assertTrue(completion.isDone()); + ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")).retire(); + } finally { + lease.close(); + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void repeatedDisableCannotRenewTheOriginalSharedDeadline() throws Exception { + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var completion = observe(fence); + var lease = fence.acquire(); + fence.retainForProcessExit(); + long original = System.nanoTime() - TimeUnit.SECONDS.toNanos(1); + fence.beginShutdown(original); + fence.beginShutdown(System.nanoTime()); + var started = (CompletableFuture) field(fence, "processShutdownStarted"); + assertEquals(original, started.get()); + fence.retire(); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var hook = (Thread) field(fence, "processHook"); + var exit = caller.submit(hook::run); + assertTrue(completion.entered.await(5, TimeUnit.SECONDS)); + assertTrue(completion.nanos > 0 && completion.nanos <= TimeUnit.SECONDS.toNanos(14), + "Process exit must use the remaining disable budget, not another fifteen seconds"); + lease.close(); + exit.get(5, TimeUnit.SECONDS); + } finally { + lease.close(); + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void expiredProcessBudgetDoesNotCertifyFailedPublicationOrLiftGenerationAdmission() throws Exception { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var completion = observe(fence); + fence.retainForProcessExit(); + fence.retainUntilSuccessfulPublication(CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O"))); + var hook = (Thread) field(fence, "processHook"); + fence.beginShutdown(System.nanoTime() - TimeUnit.SECONDS.toNanos(16)); + fence.retire(); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + caller.submit(hook::run).get(5, TimeUnit.SECONDS); + assertFalse(completion.isDone(), "Deadline expiry cannot report persistence success"); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + } finally { + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void interruptedProcessWaitPreservesInterruptionWithoutAcknowledgingTheWriter() throws Exception { + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var completion = observe(fence); + var lease = fence.acquire(); + fence.retainForProcessExit(); + fence.beginShutdown(System.nanoTime()); + fence.retire(); + var interrupted = new java.util.concurrent.atomic.AtomicBoolean(); + var hook = (Thread) field(fence, "processHook"); + var observer = new Thread(() -> { + hook.run(); + interrupted.set(Thread.currentThread().isInterrupted()); + }); + try { + observer.start(); + assertTrue(completion.entered.await(5, TimeUnit.SECONDS)); + observer.interrupt(); + observer.join(5000); + assertFalse(observer.isAlive()); + assertTrue(interrupted.get()); + assertFalse(completion.isDone()); + } finally { + lease.close(); + observer.interrupt(); + observer.join(5000); + } + } + + @Test void retainedExecutorTerminationIsIncludedWithoutAPreStorageDependencyCycle() throws Exception { + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var completion = observe(fence); + fence.retainForProcessExit(); + var writer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + writer.execute(() -> { + entered.countDown(); + try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + writer.shutdown(); + var retirement = fence.retainUntilTermination(new java.util.concurrent.ScheduledExecutorService[] { writer }); + fence.beginShutdown(System.nanoTime()); + fence.retire(); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var exit = caller.submit(((Thread) field(fence, "processHook"))::run); + assertTrue(completion.entered.await(5, TimeUnit.SECONDS)); + assertFalse(completion.isDone()); + release.countDown(); + retirement.toCompletableFuture().get(5, TimeUnit.SECONDS); + exit.get(5, TimeUnit.SECONDS); + } finally { + release.countDown(); + writer.shutdownNow(); + caller.shutdownNow(); + assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void queuedSignPublicationRemainsInTheProcessDrainAfterTheNativeSdkOwnerReleases() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var target = directory.resolve("ServerData.yml"); + var nativeOwner = com.bencodez.advancedcore.lifecycle.ServerDataOwnership.acquire(target); + var fence = ServerDataPublicationFence.beginGeneration(target); + var completion = observe(fence); + fence.retainForProcessExit(); + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + var workers = new ArrayList(); + var signs = new Signs(plugin) { + @Override protected void startShutdownWorker(Runnable worker) { workers.add(worker); } + }; + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + var sign = mock(SignHandler.class, CALLS_REAL_METHODS); + for (var entry : java.util.Map.of("sign", "0", "position", 1, + "data", "SyntheticSign").entrySet()) { + var f = SignHandler.class.getDeclaredField(entry.getKey()); + f.setAccessible(true); + f.set(sign, entry.getValue()); + } + sign.setLocation(new Location(world, 1, 2, 3)); + signs.getSigns().add(sign); + var published = signs.storeSignsForShutdown(); + long started = System.nanoTime(); + nativeOwner.beginShutdown(started); + // Exercise the actual SDK ownership release once its own aggregate has settled. + // This fixture does not simulate unrelated native cleanup actions. + nativeOwner.releaseAfter(CompletableFuture.completedFuture(null)); + assertTrue(nativeOwner.isReleased()); + fence.beginShutdown(started); + fence.retire(); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var exit = caller.submit(((Thread) field(fence, "processHook"))::run); + assertTrue(completion.entered.await(5, TimeUnit.SECONDS)); + assertFalse(completion.isDone(), "The native release cannot discard the consumer reservation"); + assertFalse(published.toCompletableFuture().isDone()); + workers.getFirst().run(); + published.toCompletableFuture().get(5, TimeUnit.SECONDS); + exit.get(5, TimeUnit.SECONDS); + assertEquals("SyntheticSign", YamlConfiguration.loadConfiguration(target.toFile()) + .getString("VotingPlugin.Signs.0.Data")); + } finally { + if (!published.toCompletableFuture().isDone()) workers.getFirst().run(); + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SignShutdownPublicationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SignShutdownPublicationTest.java new file mode 100644 index 0000000000..905ad0b1f9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/SignShutdownPublicationTest.java @@ -0,0 +1,368 @@ +package com.bencodez.votingplugin.tests; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.GenerationFencedServerDataFile; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.signs.SignHandler; +import com.bencodez.votingplugin.signs.Signs; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class SignShutdownPublicationTest { + @TempDir Path directory; + + private static final class Admission extends Signs { + final List workers = new ArrayList<>(); + boolean reject; + Admission(VotingPluginMain plugin) { super(plugin); } + @Override protected void startShutdownWorker(Runnable work) { + if (reject) throw new RejectedExecutionException("Synthetic rejection"); + workers.add(work); + } + } + + private VotingPluginMain plugin(ServerDataPublicationFence fence) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + return plugin; + } + + private SignHandler sign(VotingPluginMain plugin, String id, Location location, Location skull) throws Exception { + var sign = mock(SignHandler.class, CALLS_REAL_METHODS); + for (var entry : java.util.Map.of("plugin", plugin, "sign", id, + "data", "SyntheticSign" + id, "position", 2).entrySet()) { + var field = SignHandler.class.getDeclaredField(entry.getKey()); + field.setAccessible(true); + field.set(sign, entry.getValue()); + } + sign.setLocation(location); + sign.setSkullLocation(skull); + return sign; + } + + @Test void rejectedAdmissionRetainsTheOriginalBatchAndPublishesItOnceOnRetry() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + backing.setData(new YamlConfiguration()); + backing.getData().set("VotingPlugin.Unrelated", "preserved"); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + var signs = new Admission(plugin); + var owner = Thread.currentThread(); + var world = mock(World.class); + when(world.getName()).thenAnswer(call -> { + assertSame(owner, Thread.currentThread(), "Platform state must be captured before handoff"); + return "SyntheticWorld"; + }); + var location = new Location(world, -1.5, 64, 2.5); + var first = sign(plugin, "0", location, new Location(world, 3, 65, 4)); + signs.getSigns().add(first); + signs.getSigns().add(sign(plugin, "1", new Location(world, 5, 66, 6), null)); + signs.reject = true; + assertThrows(RejectedExecutionException.class, signs::storeSignsForShutdown); + fence.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + // Retry must use the accepted detached snapshot, not newer platform state. + location.setX(90); + first.setData("LaterUnacceptedState"); + signs.getSigns().clear(); + signs.reject = false; + var completion = signs.storeSignsForShutdown(); + signs.storeSignsForShutdown(); + assertEquals(1, signs.workers.size()); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + worker.submit(signs.workers.getFirst()).get(5, TimeUnit.SECONDS); + completion.toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals(-1, disk.getInt("VotingPlugin.Signs.0.X"), "Preserve legacy coordinate truncation"); + assertEquals(3, disk.getInt("VotingPlugin.Signs.0.Skull.X")); + assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data")); + assertEquals(2, disk.getInt("VotingPlugin.Signs.1.Position")); + assertEquals("preserved", disk.getString("VotingPlugin.Unrelated")); + signs.storeSignsForShutdown(); + assertEquals(1, signs.workers.size(), "Successful publication is idempotent"); + ServerDataPublicationFence.beginGeneration(target).retire(); + } + } + + @Test void cancellationAndForgedObservationCannotReleaseTheRealPublicationOwner() throws Exception { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var data = mock(ServerData.class); + var durable = new CompletableFuture(); + when(data.publishShutdownSigns(anyList())).thenReturn(durable); + when(plugin.getServerData()).thenReturn(data); + var signs = new Admission(plugin); + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null)); + var pending = signs.storeSignsForShutdown(); + pending.toCompletableFuture().cancel(true); + pending.toCompletableFuture().complete(null); + signs.storeSignsForShutdown(); + assertEquals(1, signs.workers.size(), "Accepted but unexecuted work retains one owner"); + fence.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + signs.workers.getFirst().run(); + assertFalse(pending.toCompletableFuture().isDone(), "Submission is not durability"); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + durable.complete(null); + pending.toCompletableFuture().get(5, TimeUnit.SECONDS); + ServerDataPublicationFence.beginGeneration(target).retire(); + } + + @Test void failedPublicationRetainsPayloadAndRequiresAnExplicitSuccessfulRetry() throws Exception { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var data = mock(ServerData.class); + var attempts = new ArrayList>(); + when(data.publishShutdownSigns(anyList())).thenAnswer(call -> { + attempts.add(call.getArgument(0)); + return attempts.size() == 1 ? CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")) + : CompletableFuture.completedFuture(null); + }); + when(plugin.getServerData()).thenReturn(data); + var signs = new Admission(plugin); + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null)); + var first = signs.storeSignsForShutdown(); + signs.workers.getFirst().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + fence.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + signs.getSigns().clear(); + var retry = signs.storeSignsForShutdown(); + signs.workers.get(1).run(); + retry.toCompletableFuture().get(5, TimeUnit.SECONDS); + assertSame(attempts.get(0), attempts.get(1), "Failure must not discard or recapture the accepted batch"); + ServerDataPublicationFence.beginGeneration(target).retire(); + } + + @Test void emptyShutdownDoesNotAdmitAWorkerOrTouchServerData() { + var plugin = mock(VotingPluginMain.class); + var signs = new Admission(plugin); + assertDoesNotThrow(() -> signs.storeSignsForShutdown().toCompletableFuture().join()); + assertEquals(0, signs.workers.size()); + verifyNoInteractions(plugin); + } + + @Test void actualSdkFailedThenSuccessfulRetryReleasesTheOriginalPublicationOwner() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var attempts = new java.util.concurrent.atomic.AtomicInteger(); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException { + if (attempts.incrementAndGet() == 1) + throw new java.io.IOException("Synthetic first publication failure"); + super.moveAtomically(source, destination); + } + }; + backing.setData(new YamlConfiguration()); + backing.getData().set("VotingPlugin.Unrelated", "preserved"); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + var signs = new Admission(plugin); + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null)); + var failed = signs.storeSignsForShutdown(); + signs.workers.getFirst().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> failed.toCompletableFuture().join()); + fence.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + facade.mutateFinalData(candidate -> candidate.set("Independent.Value", "confirmed")) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target), + "A different successful operation cannot acknowledge the failed sign batch"); + signs.getSigns().clear(); + var retry = signs.storeSignsForShutdown(); + signs.workers.get(1).run(); + retry.toCompletableFuture().get(5, TimeUnit.SECONDS); + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("SyntheticSign0", disk.getString("VotingPlugin.Signs.0.Data")); + assertEquals("preserved", disk.getString("VotingPlugin.Unrelated")); + assertEquals("confirmed", disk.getString("Independent.Value")); + assertEquals(3, attempts.get()); + signs.storeSignsForShutdown(); + assertEquals(2, signs.workers.size(), "Successful retry is not republished"); + assertDoesNotThrow(() -> ServerDataPublicationFence.beginGeneration(target).retire(), + "Confirmed retry must release both facade and sign-batch ownership"); + } + } + + @Test void actualPendingRetryCoalescesWithoutReplacingItsPayloadOrTrustingObservationCancellation() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var attempts = new java.util.concurrent.atomic.AtomicInteger(); + var retryEntered = new java.util.concurrent.CountDownLatch(1); + var releaseRetry = new java.util.concurrent.CountDownLatch(1); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException { + if (attempts.incrementAndGet() == 1) + throw new java.io.IOException("Synthetic first publication failure"); + retryEntered.countDown(); + try { + if (!releaseRetry.await(5, TimeUnit.SECONDS)) + throw new java.io.IOException("Synthetic retry barrier expired"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new java.io.IOException(interrupted); + } + super.moveAtomically(source, destination); + } + }; + backing.setData(new YamlConfiguration()); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3), + null, "OriginalAcceptedSign", 2)); + var different = List.of(ServerData.captureSignSnapshot("0", new Location(world, 9, 8, 7), + null, "DifferentUnacceptedSign", 4)); + assertThrows(java.util.concurrent.CompletionException.class, + () -> data.publishShutdownSigns(snapshot).toCompletableFuture().join()); + fence.retire(); + var workers = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var retry = workers.submit(() -> data.publishShutdownSigns(snapshot)); + assertTrue(retryEntered.await(5, TimeUnit.SECONDS)); + var coalesced = workers.submit(() -> data.publishShutdownSigns(snapshot)).get(2, TimeUnit.SECONDS); + coalesced.toCompletableFuture().cancel(true); + coalesced.toCompletableFuture().complete(null); + assertFalse(coalesced.toCompletableFuture().isDone(), "The real publication still owns its lease"); + var rejected = workers.submit(() -> data.publishShutdownSigns(different)).get(2, TimeUnit.SECONDS); + assertThrows(java.util.concurrent.CompletionException.class, () -> rejected.toCompletableFuture().join()); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + releaseRetry.countDown(); + retry.get(5, TimeUnit.SECONDS).toCompletableFuture().get(5, TimeUnit.SECONDS); + coalesced.toCompletableFuture().get(5, TimeUnit.SECONDS); + data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(2, attempts.get(), "One failed attempt and one shared durable retry"); + var disk = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("OriginalAcceptedSign", disk.getString("VotingPlugin.Signs.0.Data")); + assertEquals(1, disk.getInt("VotingPlugin.Signs.0.X")); + ServerDataPublicationFence.beginGeneration(target).retire(); + } finally { + releaseRetry.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + } + + @Test void confirmedPublicationRetriesOnlyFailedDisplayObservationWithoutRepeatingTheWrite() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var writes = new java.util.concurrent.atomic.AtomicInteger(); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException { + writes.incrementAndGet(); + super.moveAtomically(source, destination); + } + }; + backing.setData(new YamlConfiguration()); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var data = spy(new ServerData(plugin)); + when(plugin.getServerData()).thenReturn(data); + doThrow(new IllegalStateException("Synthetic observation failure")).doCallRealMethod() + .when(data).refreshVotePartyDisplay(); + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + var snapshot = List.of(ServerData.captureSignSnapshot("0", new Location(world, 1, 2, 3), + null, "OriginalAcceptedSign", 2)); + assertThrows(java.util.concurrent.CompletionException.class, + () -> data.publishShutdownSigns(snapshot).toCompletableFuture().join()); + assertEquals("OriginalAcceptedSign", YamlConfiguration.loadConfiguration(target.toFile()) + .getString("VotingPlugin.Signs.0.Data"), "The write committed before observation failed"); + fence.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target)); + data.publishShutdownSigns(snapshot).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, writes.get(), "Retry must not replay an already confirmed mutation"); + ServerDataPublicationFence.beginGeneration(target).retire(); + } + } + + @Test void actualSdkIoFailureKeepsTheGenerationBlocked() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var target = directory.resolve("ServerData.yml"); + var fence = ServerDataPublicationFence.beginGeneration(target); + var plugin = plugin(fence); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override protected void moveAtomically(Path source, Path destination) throws java.io.IOException { + throw new java.io.IOException("Synthetic failed atomic publication"); + } + }; + backing.setData(new YamlConfiguration()); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + var signs = new Admission(plugin); + var world = mock(World.class); + when(world.getName()).thenReturn("SyntheticWorld"); + signs.getSigns().add(sign(plugin, "0", new Location(world, 1, 2, 3), null)); + var result = signs.storeSignsForShutdown(); + signs.workers.getFirst().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> result.toCompletableFuture().join()); + assertFalse(java.nio.file.Files.exists(target)); + fence.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(target), + "Neither the SDK guard nor sign handoff may call failed I/O durable"); + } + } + + @Test void runtimeSignSavingRemainsSynchronous() throws Exception { + var plugin = mock(VotingPluginMain.class); + var data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + var signs = new Admission(plugin); + var location = new Location(mock(World.class), 1, 2, 3); + signs.getSigns().add(sign(plugin, "0", location, null)); + var caller = Thread.currentThread(); + doAnswer(call -> { assertSame(caller, Thread.currentThread()); return null; }) + .when(data).setSign("0", location, null, "SyntheticSign0", 2); + signs.storeSigns(); + verify(data).setSign("0", location, null, "SyntheticSign0", 2); + assertEquals(0, signs.workers.size()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java index e89d6376f5..d5ab29d027 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotePartyTest.java @@ -88,7 +88,7 @@ public void tearDown() { public void addTotal_IncrementsTotalVotes() { when(plugin.getServerData().getData().getInt("VoteParty.Total")).thenReturn(5); voteParty.addTotal(user); - verify(plugin.getServerData().getData()).set("VoteParty.Total", 6); + verify(plugin.getServerData()).setInt("VoteParty.Total", 6); } @Test @@ -114,6 +114,8 @@ public void check_NotEnoughVotes_DoesNotTriggerVoteParty() { @Test public void commandVoteParty_VotePartyEnabled_SendsMessage() { + when(plugin.getServerData().getVotePartyDisplay()).thenReturn( + new com.bencodez.votingplugin.data.ServerData.VotePartyDisplay(0, 0)); CommandSender sender = Mockito.mock(CommandSender.class); when(plugin.getSpecialRewardsConfig().isVotePartyEnabled()).thenReturn(true); when(plugin.getConfigFile().getFormatCommandsVoteParty()).thenReturn(new ArrayList<>()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginMainShutdownTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginMainShutdownTest.java new file mode 100644 index 0000000000..bd72659174 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/VotingPluginMainShutdownTest.java @@ -0,0 +1,603 @@ +package com.bencodez.votingplugin.tests; + +import com.bencodez.votingplugin.*; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.concurrent.ScheduledExecutorService; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.signs.Signs; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; +import com.bencodez.votingplugin.placeholders.PlaceholderPlayerPresence; +import com.bencodez.votingplugin.webhook.VotingPluginWebhookManager; + +class VotingPluginMainShutdownTest { + @Test + void lateVoteTimerIsOptionalDuringPartialStartup() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + invokeShutdownVoteTimer(plugin); + assertNull(field(plugin, "voteTimer")); + } + + @Test + void voteTimerCleanupIsRepeatable() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + ScheduledExecutorService timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + setField(plugin, "voteTimer", timer); + invokeShutdownVoteTimer(plugin); + assertDoesNotThrow(() -> invokeShutdownVoteTimer(plugin)); + org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer")); + org.junit.jupiter.api.Assertions.assertTrue(timer.isShutdown()); + } + + @Test + void publicUnloadSkipsLateOptionalResourcesAndIsIdempotent() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "plugin", plugin); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + assertNull(field(plugin, "signs")); + assertNull(field(plugin, "timeQueueHandler")); + verify((java.util.concurrent.ExecutorService) field(plugin, "backendHostedControlLifecycle")).shutdownNow(); + verify((java.util.concurrent.ExecutorService) field(plugin, "backendControlConnectorLifecycle")).shutdownNow(); + } + + @Test + void stoppedTimerRemainsVisibleUntilOverflowAndProducerHandlersClose() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class); + setField(plugin, "voteTimer", timer); + setField(plugin, "votifierVoteOverflowQueue", queue); + var duringClose = new java.util.concurrent.atomic.AtomicReference(); + var rejected = new java.util.concurrent.atomic.AtomicBoolean(); + var ran = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { + duringClose.set(plugin.getVoteTimer()); + rejected.set(!com.bencodez.votingplugin.util.VoteTaskAdmission.trySubmit(plugin.getVoteTimer(), + () -> ran.set(true))); + return null; + }).when(queue).close(); + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + plugin.onUnLoad(); + org.junit.jupiter.api.Assertions.assertSame(timer, duringClose.get()); + org.junit.jupiter.api.Assertions.assertTrue(rejected.get()); + org.junit.jupiter.api.Assertions.assertFalse(ran.get()); + handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin)); + assertNull(plugin.getVoteTimer()); + } finally { timer.shutdownNow(); } + } + + @Test + void integratedUnloadClosesTheCurrentStreakMenuOnce() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class); + var signs = shutdownSignsMock(); + setField(plugin, "voteStreaksMenu", menu); + setField(plugin, "signs", signs); + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + verify(menu).close(); + verify(signs).storeSignsForShutdown(); + } + + @Test + void failedStreakMenuCloseDoesNotSkipOtherCleanupAndRetriesOnRepeat() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var menu = mock(com.bencodez.votingplugin.streaks.VoteStreaksMenu.class); + var signs = shutdownSignsMock(); + setField(plugin, "voteStreaksMenu", menu); + setField(plugin, "signs", signs); + doThrow(new IllegalStateException("synthetic close failure")).doNothing().when(menu).close(); + assertDoesNotThrow(plugin::onUnLoad); + verify(signs).storeSignsForShutdown(); + assertDoesNotThrow(plugin::onUnLoad); + verify(menu, org.mockito.Mockito.times(2)).close(); + verify(signs).storeSignsForShutdown(); + } + + @Test + void failedDiscordCloseDoesNotSkipDisplayRetirementAndRetriesOnlyTheFailedStep() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var generation = new java.util.concurrent.atomic.AtomicLong(); + setField(plugin, "topVoterDisplayGeneration", generation); + var discord = mock(com.bencodez.votingplugin.discord.DiscordHandler.class); + setField(plugin, "discordHandler", discord); + doThrow(new IllegalStateException("synthetic Discord close failure")).doNothing().when(discord).close(); + var pending = new java.util.concurrent.CompletableFuture(); + var filter = new com.bencodez.votingplugin.topvoter.TopVoterDisplayFilter(row -> pending); + setField(plugin, "topVoterDisplayFilter", filter); + var rankings = new java.util.LinkedHashMap(); + rankings.put(new com.bencodez.votingplugin.topvoter.TopVoterPlayer(java.util.UUID.randomUUID(), "Example", 1L), 10); + var display = filter.projectAsync(rankings, true).toCompletableFuture(); + org.junit.jupiter.api.Assertions.assertFalse(display.isDone()); + var signs = shutdownSignsMock(); + setField(plugin, "signs", signs); + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + assertDoesNotThrow(plugin::onUnLoad); + org.junit.jupiter.api.Assertions.assertTrue(display.isDone(), "A failed independent close must not strand pending display evidence"); + org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null)); + verify(signs).storeSignsForShutdown(); + assertDoesNotThrow(plugin::onUnLoad); + verify(discord, org.mockito.Mockito.times(2)).close(); + verify(discord).retireLeaderboardPublications(); + verify(signs).storeSignsForShutdown(); + org.junit.jupiter.api.Assertions.assertEquals(1L, generation.get()); + pending.complete(com.bencodez.advancedcore.api.user.presence.PlayerPresence.NEVER_JOINED); + org.junit.jupiter.api.Assertions.assertEquals(rankings, display.getNow(null)); + } + } + + @Test + void publicUnloadCleansInitializedQueueAndSignResources() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "plugin", plugin); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + Signs signs = shutdownSignsMock(); + TimeQueueHandler timeQueue = mock(TimeQueueHandler.class); + setField(plugin, "signs", signs); + setField(plugin, "timeQueueHandler", timeQueue); + + plugin.onUnLoad(); + + verify(signs).storeSignsForShutdown(); + verify(timeQueue).saveForShutdown(); + plugin.onUnLoad(); + verify(signs).storeSignsForShutdown(); + } + + @Test + void failedCleanupDoesNotSkipLaterResources() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "plugin", plugin); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + Signs signs = shutdownSignsMock(); + TimeQueueHandler timeQueue = mock(TimeQueueHandler.class); + VotingPluginWebhookManager webhook = mock(VotingPluginWebhookManager.class); + doThrow(new IllegalStateException("webhook close failed")).when(webhook).shutdown(); + doThrow(new IllegalStateException("queue save failed")).when(timeQueue).saveForShutdown(); + setField(plugin, "webhookManager", webhook); + setField(plugin, "signs", signs); + setField(plugin, "timeQueueHandler", timeQueue); + + assertDoesNotThrow(plugin::onUnLoad); + + verify(webhook).shutdown(); + verify(timeQueue).saveForShutdown(); + verify(signs).storeSignsForShutdown(); + } + + @Test + void repeatedUnloadRetriesFailedBackendAndWebhookWithoutRepeatingSuccessfulResources() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class); + var webhook = mock(VotingPluginWebhookManager.class); + var signs = shutdownSignsMock(); + org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler(); + org.mockito.Mockito.doThrow(new IllegalStateException("synthetic backend close failure")).doNothing() + .when(backend).closeForShutdown(); + org.mockito.Mockito.doThrow(new IllegalStateException("synthetic webhook close failure")).doNothing() + .when(webhook).shutdown(); + setField(plugin, "webhookManager", webhook); setField(plugin, "signs", signs); + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + verify(backend, org.mockito.Mockito.times(2)).closeForShutdown(); + verify(webhook, org.mockito.Mockito.times(2)).shutdown(); + verify(signs).storeSignsForShutdown(); + handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin)); + } + } + + @Test + void failedTimerShutdownRetainsItsReferenceUntilARepeatCanStopIt() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var timer = mock(ScheduledExecutorService.class); + org.mockito.Mockito.doThrow(new IllegalStateException("synthetic forced stop failure")) + .doThrow(new IllegalStateException("synthetic repeated forced stop failure")) + .doAnswer(call -> java.util.List.of()) + .when(timer).shutdownNow(); + setField(plugin, "voteTimer", timer); + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + assertDoesNotThrow(plugin::onUnLoad); + org.junit.jupiter.api.Assertions.assertSame(timer, plugin.getVoteTimer()); + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + assertNull(plugin.getVoteTimer()); + verify(timer, org.mockito.Mockito.times(2)).shutdown(); + verify(timer, org.mockito.Mockito.times(3)).shutdownNow(); + handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin)); + } + } + + @Test + void concurrentUnloadDoesNotRepeatAnActiveResourceClose() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var backend = mock(com.bencodez.votingplugin.backendproxy.BackendProxyHandler.class); + org.mockito.Mockito.doReturn(backend).when(plugin).getBackendProxyHandler(); + var entered = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + org.mockito.Mockito.doAnswer(call -> { + entered.countDown(); + org.junit.jupiter.api.Assertions.assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS)); + return null; + }).when(backend).closeForShutdown(); + try { + var first = worker.submit(plugin::onUnLoad); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + assertDoesNotThrow(plugin::onUnLoad); + verify(backend).closeForShutdown(); + release.countDown(); first.get(5, java.util.concurrent.TimeUnit.SECONDS); + assertDoesNotThrow(plugin::onUnLoad); + verify(backend).closeForShutdown(); + } finally { + release.countDown(); worker.shutdownNow(); + org.junit.jupiter.api.Assertions.assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + + @Test + void repeatedUnloadRetriesOnlyFailedTimedVotePersistence() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + TimeQueueHandler timeQueue = mock(TimeQueueHandler.class); + Signs signs = shutdownSignsMock(); + org.mockito.Mockito.doThrow(new IllegalStateException("disk unavailable")).doNothing() + .when(timeQueue).saveForShutdown(); + setField(plugin, "timeQueueHandler", timeQueue); + setField(plugin, "signs", signs); + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + verify(timeQueue, org.mockito.Mockito.times(2)).saveForShutdown(); + verify(signs).storeSignsForShutdown(); + } + + @Test + void gracefulTimerShutdownDoesNotForceStop() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true); + setField(plugin, "voteTimer", timer); + + invokeShutdownVoteTimer(plugin); + + verify(timer).shutdown(); + verify(timer, never()).shutdownNow(); + } + + @Test + void timerTimeoutForcesStop() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + org.mockito.Mockito.when(timer.awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(false); + setField(plugin, "voteTimer", timer); + + invokeShutdownVoteTimer(plugin); + + verify(timer).shutdownNow(); + } + + @Test + void interruptedTimerShutdownRestoresInterruptAndForcesStop() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doThrow(new InterruptedException()).when(timer).awaitTermination(1, java.util.concurrent.TimeUnit.SECONDS); + setField(plugin, "voteTimer", timer); + + invokeShutdownVoteTimer(plugin); + + try { + org.junit.jupiter.api.Assertions.assertTrue(Thread.currentThread().isInterrupted()); + } finally { + Thread.interrupted(); + } + verify(timer).shutdownNow(); + } + + @Test + void failedForcedTimerStopStillClosesInitializedQueuesAndListenersOnce() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "plugin", plugin); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + doThrow(new IllegalStateException("controlled forced-stop failure")).when(timer).shutdownNow(); + setField(plugin, "voteTimer", timer); + var queue = mock(com.bencodez.votingplugin.listeners.VotifierVoteOverflowQueue.class); + var cooldown = mock(com.bencodez.votingplugin.cooldown.CoolDownCheck.class); + var reminders = mock(com.bencodez.votingplugin.votereminding.VoteRemindersManager.class); + setField(plugin, "votifierVoteOverflowQueue", queue); + setField(plugin, "coolDownCheck", cooldown); + setField(plugin, "voteRemindersManager", reminders); + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + assertDoesNotThrow(plugin::onUnLoad); + assertDoesNotThrow(plugin::onUnLoad); + verify(queue).close(); + verify(cooldown).shutdown(); + verify(reminders).shutdown(); + handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin)); + } + org.junit.jupiter.api.Assertions.assertSame(timer, field(plugin, "voteTimer")); + verify(timer, org.mockito.Mockito.times(4)).shutdownNow(); + assertNull(field(plugin, "votifierVoteOverflowQueue")); + } + + @Test + void rejectedMysqlStartupRetainsItsCauseAndFullDisableCleansEarlyResources() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + org.mockito.Mockito.doReturn(options).when(plugin).getOptions(); + org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("startup-fixture")).when(plugin).getLogger(); + org.mockito.Mockito.when(options.getDebug().isDebug()).thenReturn(true); + setField(plugin, "plugin", plugin); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var logger = mock(java.util.logging.Logger.class); + org.mockito.Mockito.doReturn(logger).when(plugin).getLogger(); + ScheduledExecutorService coreTimer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + setField(plugin, "timer", coreTimer); + setField(plugin, "loadUserData", true); + var configuration = new org.bukkit.configuration.file.YamlConfiguration(); + configuration.set("Host", "127.0.0.1"); configuration.set("Port", 3306); + configuration.set("Username", "fixture"); configuration.set("Password", "fixture"); + configuration.set("Database", "fixture"); configuration.set("MaxConnections", 1); + configuration.set("ConnectionTimeout", 1000); configuration.set("MinimumIdle", 0); + configuration.set("Driver", RejectingDriver.class.getName()); + configuration.set("Debug", true); + try { + IllegalStateException failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, + () -> new com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL(plugin, "Users", configuration)); + Throwable original = failure; + while (original.getCause() != null) original = original.getCause(); + org.junit.jupiter.api.Assertions.assertInstanceOf(java.sql.SQLException.class, original); + org.junit.jupiter.api.Assertions.assertEquals("controlled authentication rejection", original.getMessage()); + assertNull(plugin.getMysql(), "rejected constructor must not publish storage"); + assertDoesNotThrow(plugin::onDisable); + org.junit.jupiter.api.Assertions.assertTrue(coreTimer.isTerminated()); + assertNull(field(plugin, "voteTimer")); + assertNull(field(plugin, "signs")); + assertDoesNotThrow(plugin::onDisable); + org.junit.jupiter.api.Assertions.assertSame(original, rootCause(failure)); + verify(logger, never()).warning(org.mockito.ArgumentMatchers.anyString()); + verify(logger, never()).log(org.mockito.ArgumentMatchers.eq(java.util.logging.Level.WARNING), + org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.any(Throwable.class)); + } finally { coreTimer.shutdownNow(); } + } + + @Test + void signPersistenceDoesNotReblockUnloadAfterATimedVoteCheckpointTimesOut( + @org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception { + var entered = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + var firstMove = new java.util.concurrent.atomic.AtomicBoolean(true); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + var producers = new java.util.concurrent.ScheduledThreadPoolExecutor(1); + producers.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + try (var schedulers = org.mockito.Mockito.mockConstruction( + com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + org.mockito.Mockito.doReturn(directory.toFile()).when(plugin).getDataFolder(); + var fence = com.bencodez.votingplugin.util.ServerDataPublicationFence.beginGeneration( + directory.resolve("ServerData.yml")); + setField(plugin, "serverDataPublicationFence", fence); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override protected void moveAtomically(java.nio.file.Path source, java.nio.file.Path target) + throws java.io.IOException { + if (firstMove.compareAndSet(true, false)) { + entered.countDown(); + try { + if (!release.await(10, java.util.concurrent.TimeUnit.SECONDS)) + throw new java.io.IOException("Synthetic stalled writer was not released"); + } catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new java.io.IOException(failure); + } + } + super.moveAtomically(source, target); + } + }; + backing.setData(new org.bukkit.configuration.file.YamlConfiguration()); + var facade = new com.bencodez.votingplugin.data.GenerationFencedServerDataFile(plugin, backing, fence); + org.mockito.Mockito.doReturn(facade).when(plugin).getServerDataFile(); + var data = new com.bencodez.votingplugin.data.ServerData(plugin); + org.mockito.Mockito.doReturn(data).when(plugin).getServerData(); + org.mockito.Mockito.doReturn(producers).when(plugin).getVoteTimer(); + var queue = new TimeQueueHandler(plugin); + queue.addVote("SyntheticVoter", "SyntheticSite"); + producers.shutdown(); + org.junit.jupiter.api.Assertions.assertTrue(producers.awaitTermination(2, + java.util.concurrent.TimeUnit.SECONDS)); + setField(plugin, "timeQueueHandler", queue); + var signs = new Signs(plugin); + var sign = mock(com.bencodez.votingplugin.signs.SignHandler.class, CALLS_REAL_METHODS); + for (var entry : java.util.Map.of("plugin", plugin, "sign", "0", + "data", "SyntheticSign", "position", 1).entrySet()) { + var f = com.bencodez.votingplugin.signs.SignHandler.class.getDeclaredField(entry.getKey()); + f.setAccessible(true); + f.set(sign, entry.getValue()); + } + var world = mock(org.bukkit.World.class); + org.mockito.Mockito.when(world.getName()).thenReturn("SyntheticWorld"); + sign.setLocation(new org.bukkit.Location(world, -1.5, 64, 2.5)); + signs.getSigns().add(sign); + setField(plugin, "signs", signs); + try { + var unload = caller.submit(() -> { + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + plugin.onUnLoad(); + handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin)); + } + }); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, + java.util.concurrent.TimeUnit.SECONDS), "Actual SDK file writer did not reach disk boundary"); + org.junit.jupiter.api.Assertions.assertDoesNotThrow( + () -> unload.get(3, java.util.concurrent.TimeUnit.SECONDS), + "Sign persistence must not reacquire the stalled YAML monitor on the unload caller"); + org.junit.jupiter.api.Assertions.assertEquals(1, release.getCount(), + "Unload must complete before the stalled writer is released"); + } finally { + release.countDown(); + var pendingSigns = (java.util.concurrent.CompletionStage) field(plugin, "signShutdownPublication"); + if (pendingSigns != null) pendingSigns.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + queue.saveForShutdown(); + var disk = org.bukkit.configuration.file.YamlConfiguration.loadConfiguration( + directory.resolve("ServerData.yml").toFile()); + org.junit.jupiter.api.Assertions.assertEquals("SyntheticSign", disk.getString("VotingPlugin.Signs.0.Data")); + org.junit.jupiter.api.Assertions.assertEquals(1, data.getTimedVoteCacheKeys().size(), + "Final sign publication must preserve the timed-vote checkpoint"); + caller.shutdown(); + org.junit.jupiter.api.Assertions.assertTrue(caller.awaitTermination(5, + java.util.concurrent.TimeUnit.SECONDS)); + fence.retire(); + } + } finally { + release.countDown(); + caller.shutdownNow(); + producers.shutdownNow(); + } + } + + @Test + void repeatedUnloadRetriesAFailedSignPublicationWithoutRepeatingListenerCleanup() throws Exception { + var plugin = mock(VotingPluginMain.class, CALLS_REAL_METHODS); + setField(plugin, "unloadInProgress", new java.util.concurrent.atomic.AtomicBoolean()); + setField(plugin, "placeholderPlayerPresence", new PlaceholderPlayerPresence()); + initializeEarlyLifecycle(plugin); + var signs = mock(Signs.class); + var pending = new java.util.concurrent.CompletableFuture(); + org.mockito.Mockito.when(signs.storeSignsForShutdown()).thenReturn( + java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("Synthetic I/O")), + pending.minimalCompletionStage()); + setField(plugin, "signs", signs); + try (var handlers = org.mockito.Mockito.mockStatic(org.bukkit.event.HandlerList.class)) { + plugin.onUnLoad(); + plugin.onUnLoad(); + plugin.onUnLoad(); + verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown(); + verify(signs, never()).storeSigns(); + handlers.verify(() -> org.bukkit.event.HandlerList.unregisterAll(plugin)); + pending.complete(null); + plugin.onUnLoad(); + verify(signs, org.mockito.Mockito.times(2)).storeSignsForShutdown(); + } + } + + private static Signs shutdownSignsMock() { + var signs = mock(Signs.class); + org.mockito.Mockito.when(signs.storeSignsForShutdown()) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + return signs; + } + + private static Throwable rootCause(Throwable failure) { + while (failure.getCause() != null) failure = failure.getCause(); + return failure; + } + + public static final class RejectingDriver implements java.sql.Driver { + public java.sql.Connection connect(String url, java.util.Properties properties) throws java.sql.SQLException { + throw new java.sql.SQLException("controlled authentication rejection", "28000", 1045); + } + public boolean acceptsURL(String url) { return url != null && url.startsWith("jdbc:"); } + public java.sql.DriverPropertyInfo[] getPropertyInfo(String url, java.util.Properties properties) { + return new java.sql.DriverPropertyInfo[0]; + } + public int getMajorVersion() { return 1; } + public int getMinorVersion() { return 0; } + public boolean jdbcCompliant() { return false; } + public java.util.logging.Logger getParentLogger() { return java.util.logging.Logger.getGlobal(); } + } + + private static void initializeEarlyLifecycle(VotingPluginMain plugin) throws Exception { + // The real plugin initializes this display retirement counter before startup. + setField(plugin, "topVoterDisplayGeneration", new java.util.concurrent.atomic.AtomicLong()); + // Mockito bypasses the SDK constructor that creates this lock. + setField(plugin, "lifecycleLock", new Object()); + var hosted = mock(java.util.concurrent.ExecutorService.class); + var connector = mock(ScheduledExecutorService.class); + org.mockito.Mockito.when(hosted.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true); + org.mockito.Mockito.when(connector.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)).thenReturn(true); + setField(plugin, "pendingNativeUserStorageCloseLock", new Object()); + setField(plugin, "pendingNativeUserStorageCloses", new java.util.ArrayList<>()); + setField(plugin, "backendHostedControlLifecycle", hosted); + setField(plugin, "backendControlConnectorLifecycle", connector); + setField(plugin, "backendHostedControlManagers", java.util.concurrent.ConcurrentHashMap.newKeySet()); + org.mockito.Mockito.doReturn(java.util.logging.Logger.getLogger("shutdown-fixture")).when(plugin).getLogger(); + } + + private static void invokeShutdownVoteTimer(VotingPluginMain plugin) throws Exception { + Method method = VotingPluginMain.class.getDeclaredMethod("shutdownVoteTimer"); + method.setAccessible(true); + method.invoke(plugin); + } + + private static Object field(VotingPluginMain plugin, String name) throws Exception { + Field field = VotingPluginMain.class.getDeclaredField(name); + field.setAccessible(true); + return field.get(plugin); + } + + private static void setField(VotingPluginMain plugin, String name, Object value) throws Exception { + Field field = findField(name); + field.setAccessible(true); + field.set(plugin, value); + } + + private static Field findField(String name) throws Exception { + Class type = VotingPluginMain.class; + while (type != null) { + try { return type.getDeclaredField(name); } + catch (NoSuchFieldException ignored) { type = type.getSuperclass(); } + } + throw new NoSuchFieldException(name); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/discord/DiscordLeaderboardRecoveryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/discord/DiscordLeaderboardRecoveryTest.java index 0b9430cbd3..ac1de18b23 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/discord/DiscordLeaderboardRecoveryTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/discord/DiscordLeaderboardRecoveryTest.java @@ -383,7 +383,8 @@ void assertDeletionQueued() { var pathField=com.bencodez.simpleapi.file.YMLFile.class.getDeclaredField("dFile"); pathField.setAccessible(true);pathField.set(file,directory.toFile()); when(f.plugin.getServerDataFile()).thenReturn(file); - when(f.plugin.getServerData()).thenReturn(new ServerData(f.plugin)); + var serverData = new ServerData(f.plugin); + when(f.plugin.getServerData()).thenReturn(serverData); startPersistentSend(f,false);var created=new CreatedMessage(12L); f.sendSuccess.accept(created.message);f.drain(); assertEquals(12L,yaml.getLong("VotingPlugin.DiscordSRV.TopVoterMessageId.Monthly")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java index c43d21301e..735f42c244 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/listeners/VotiferEventDisabledVoteSiteTest.java @@ -167,4 +167,13 @@ public void testVoteIsQueuedWhenBoundedExecutorRejectsIt() { verify(overflowQueue).enqueue("Steve", SERVICE_SITE); verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); } + + @Test + void unavailableTimerUsesTheSameOverflowAdmissionPath() { + when(plugin.getVoteTimer()).thenReturn(null); + when(overflowQueue.enqueue("Steve", SERVICE_SITE)).thenReturn(true); + listener.onVotiferEvent(createVoteEvent(SERVICE_SITE)); + verify(overflowQueue).enqueue("Steve", SERVICE_SITE); + verify(pluginManager, never()).callEvent(any(PlayerVoteEvent.class)); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/VotePartyPublicationReadTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/VotePartyPublicationReadTest.java new file mode 100644 index 0000000000..e503d58a93 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/placeholders/VotePartyPublicationReadTest.java @@ -0,0 +1,787 @@ +package com.bencodez.votingplugin.tests.placeholders; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.data.GenerationFencedServerDataFile; +import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel; +import com.bencodez.votingplugin.specialrewards.voteparty.VoteParty; +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class VotePartyPublicationReadTest { + @TempDir Path directory; + + @Test void allVotePartyPlaceholdersReturnWhilePublicationIsStalled() throws Exception { + var previousPlugin = VotingPluginMain.plugin; + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginMain.plugin = plugin; + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.NONE); + when(plugin.getSpecialRewardsConfig().getVotePartyVotesRequired()).thenReturn(20); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + var armed = new AtomicBoolean(); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void reloadData() { + getData().set("VotingPlugin.VoteParty.Total", 31); + getData().set("VotingPlugin.VotePartyExtraRequired", 4); + } + + @Override public void saveData() { + assertTrue(Thread.holdsLock(publicationMonitor())); + if (armed.get()) { + entered.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + } + } + }; + var yaml = new YamlConfiguration(); + yaml.set("VotingPlugin.VoteParty.Total", 7); + yaml.set("VotingPlugin.VotePartyExtraRequired", 3); + backing.setData(yaml); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + var exposed = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataFile()).thenReturn(exposed); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + when(plugin.getVoteParty()).thenReturn(new VoteParty(plugin)); + data.saveData(); + var placeholders = new PlaceHolders(plugin); + placeholders.load(); + when(plugin.getSpecialRewardsConfig().isVotePartyEnabled()).thenReturn(true); + when(plugin.getConfigFile().getFormatCommandsVoteParty()) + .thenReturn(new java.util.ArrayList<>(java.util.List.of("%votes%/%votesrequired%/%neededvotes%"))); + var sender = mock(org.bukkit.command.CommandSender.class); + var workers = Executors.newFixedThreadPool(2); + try { + armed.set(true); + var saving = workers.submit(() -> data.setInt("VoteParty.Total", 9)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var reading = workers.submit(() -> { + assertEquals("9", value(placeholders, "VotePartyVotesCurrent")); + assertEquals("23", value(placeholders, "VotePartyVotesRequired")); + assertEquals("14", value(placeholders, "VotePartyVotesNeeded")); + plugin.getVoteParty().commandVoteParty(sender); + verify(sender).sendMessage(new String[] { "9/23/14" }); + }); + reading.get(2, TimeUnit.SECONDS); + assertFalse(saving.isDone(), "the read must finish before the disk writer resumes"); + release.countDown(); + saving.get(5, TimeUnit.SECONDS); + assertEquals(9, plugin.getVoteParty().getTotalVotes(), "accounting still reads authoritative YAML"); + data.setVotePartyExtraRequired(-2); + assertEquals("20", value(placeholders, "VotePartyVotesRequired")); + assertEquals("11", value(placeholders, "VotePartyVotesNeeded")); + data.setVotePartyExtraRequired(6); + assertEquals("26", value(placeholders, "VotePartyVotesRequired")); + assertEquals("17", value(placeholders, "VotePartyVotesNeeded")); + exposed.reloadData(); + assertEquals("31", value(placeholders, "VotePartyVotesCurrent")); + assertEquals("24", value(placeholders, "VotePartyVotesRequired")); + assertEquals("-7", value(placeholders, "VotePartyVotesNeeded")); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + fence.retire(); + } + } finally { VotingPluginMain.plugin = previousPlugin; } + } + + @Test void preLoadConstructionRemainsLazyUntilTheSdkCreatesItsFile() { + var plugin = mock(VotingPluginMain.class); + var data = new ServerData(plugin); + assertEquals(0, data.getVotePartyDisplay().total()); + verify(plugin, never()).getServerDataFile(); + } + + @Test void startupReloadAndFailedSaveRetainLiveDisplaySemantics() { + var plugin = mock(VotingPluginMain.class); + var backing = mock(com.bencodez.advancedcore.data.ServerData.class); + doCallRealMethod().when(backing).mutateOrdinaryData(any()); + when(backing.publicationMonitor()).thenReturn(new Object()); + var initial = new YamlConfiguration(); + initial.set("VotingPlugin.VoteParty.Total", 11); + initial.set("VotingPlugin.VotePartyExtraRequired", 4); + when(backing.getData()).thenReturn(initial); + when(plugin.getServerDataFile()).thenReturn(backing); + var data = new ServerData(plugin); + data.refreshVotePartyDisplay(); + var first = data.getVotePartyDisplay(); + assertEquals(11, first.total()); + assertEquals(24, first.required(20)); + verify(backing, never()).saveData(); + + var reloaded = new YamlConfiguration(); + reloaded.set("VotingPlugin.VoteParty.Total", 13); + doAnswer(invocation -> { when(backing.getData()).thenReturn(reloaded); return null; }) + .when(backing).reloadData(); + data.reloadData(); + assertEquals(13, data.getVotePartyDisplay().total()); + assertEquals(20, data.getVotePartyDisplay().required(20)); + assertEquals(11, first.total(), "previously published snapshots remain immutable"); + + doThrow(new IllegalStateException("Synthetic publication failure")).when(backing).saveData(); + assertThrows(IllegalStateException.class, () -> data.setInt("VoteParty.Total", 17)); + assertEquals(17, data.getVotePartyDisplay().total(), "display follows live YAML, not a durability claim"); + assertEquals(17, data.getData().getInt("VoteParty.Total")); + } + + @Test void sdkSetDataPublishesItsLiveDisplayBeforeDiskIo() throws Exception { + sdkMutationAndSaveEntryPoint("setData"); + } + + @Test void sdkSetValuePublishesItsLiveDisplayBeforeDiskIo() throws Exception { + sdkMutationAndSaveEntryPoint("setValue"); + } + + @Test void sdkSaveDataPublishesItsLiveDisplayBeforeDiskIo() throws Exception { + sdkMutationAndSaveEntryPoint("saveData"); + } + + @Test void sdkCheckedSavePublishesItsLiveDisplayBeforeDiskIo() throws Exception { + sdkMutationAndSaveEntryPoint("saveDataChecked"); + } + + private void sdkMutationAndSaveEntryPoint(String operation) throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + var yaml = spy(new YamlConfiguration()); + yaml.set("VotingPlugin.VoteParty.Total", 7); + yaml.set("VotingPlugin.VotePartyExtraRequired", 3); + backing.setData(yaml); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var exposed = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + when(plugin.getServerDataFile()).thenReturn(exposed); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + data.refreshVotePartyDisplay(); + var saves = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { + assertTrue(Thread.holdsLock(backing), "the actual SDK owner must hold live YAML"); + assertEquals(17, yaml.getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, data.getVotePartyDisplay().total(), + "display must be refreshed before the blocking backing save"); + assertEquals(3, data.getVotePartyDisplay().extraRequired()); + saves.incrementAndGet(); + return call.callRealMethod(); + }).when(yaml).save(any(java.io.File.class)); + try { + switch (operation) { + case "setData" -> exposed.setData("VotingPlugin.VoteParty.Total", 17); + case "setValue" -> exposed.setValue("VotingPlugin.VoteParty.Total", 17); + case "saveData" -> { + synchronized (exposed.publicationMonitor()) { + exposed.getData().set("VotingPlugin.VoteParty.Total", 17); + exposed.saveData(); + } + } + case "saveDataChecked" -> { + synchronized (exposed.publicationMonitor()) { + exposed.getData().set("VotingPlugin.VoteParty.Total", 17); + exposed.saveDataChecked(); + } + } + default -> throw new AssertionError(operation); + } + assertEquals(1, saves.get(), "one real backing save must be exercised"); + assertEquals(17, YamlConfiguration.loadConfiguration(backing.getdFile()) + .getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, data.getVotePartyDisplay().total()); + } finally { fence.retire(); } + } + } + + @Test void sdkBulkMergeKeepsItsNoSaveSemanticsAndRefreshesTheLiveDisplay() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + var original = new YamlConfiguration(); + backing.setData(original); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var exposed = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + when(plugin.getServerDataFile()).thenReturn(exposed); + var data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + data.refreshVotePartyDisplay(); + var replacement = new YamlConfiguration(); + replacement.set("VotingPlugin.VoteParty.Total", 19); + replacement.set("VotingPlugin.VotePartyExtraRequired", 4); + try { + exposed.setData(replacement); + assertSame(original, exposed.getData(), "bulk merge must retain the SDK's existing live YAML identity"); + assertFalse(java.nio.file.Files.exists(backing.getdFile().toPath()), "bulk merge must not save"); + assertEquals(19, data.getVotePartyDisplay().total()); + assertEquals(4, data.getVotePartyDisplay().extraRequired()); + } finally { fence.retire(); } + } + } + + @Test void nativeFinalPublicationRefreshesOnlyAfterItsLiveCommit() throws Exception { + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void saveDataChecked() { + assertTrue(Thread.holdsLock(publicationMonitor())); + entered.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { throw new AssertionError(failure); } + super.saveDataChecked(); + } + })) { + var workers = Executors.newFixedThreadPool(2); + var original = fixture.data.getVotePartyDisplay(); + try { + var publication = workers.submit(() -> fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var reading = workers.submit(() -> fixture.data.getVotePartyDisplay()); + assertEquals(7, reading.get(2, TimeUnit.SECONDS).total(), + "an unpublished final candidate must not replace the live projection"); + assertFalse(publication.isDone()); + release.countDown(); + publication.get(5, TimeUnit.SECONDS); + assertSame(fixture.yaml, fixture.backing.getData()); + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, fixture.data.getVotePartyDisplay().total(), "confirmed native commit must refresh display"); + assertEquals(7, original.total(), "prior published records remain immutable"); + assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile()) + .getInt("VotingPlugin.VoteParty.Total")); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + } + + @Test void failedNativeFinalCandidateKeepsTheLiveProjectionAndRetainsAdmission() throws Exception { + var failure = new IllegalStateException("Synthetic checked publication failure"); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void saveDataChecked() { throw failure; } + })) { + var observation = fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + var observed = assertThrows(java.util.concurrent.ExecutionException.class, + () -> observation.get(5, TimeUnit.SECONDS)); + assertSame(failure, observed.getCause()); + assertEquals(7, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(7, fixture.data.getVotePartyDisplay().total()); + assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath())); + fixture.fence.retire(); + assertThrows(IllegalStateException.class, () -> + ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath())); + } + } + + @Test void legacyFinalMutationRefreshesBeforeSaveWithoutClaimingDurableAcknowledgement() throws Exception { + var display = new java.util.concurrent.atomic.AtomicReference(); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void saveData() { + assertTrue(Thread.holdsLock(publicationMonitor())); + assertEquals(17, display.get().getVotePartyDisplay().total(), + "a legacy owner's live mutation must be visible before its disk save"); + super.saveData(); + } + })) { + display.set(fixture.data); + var observation = fixture.exposed.mutateFinalData(live -> + live.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> observation.get(5, TimeUnit.SECONDS)); + assertInstanceOf(UnsupportedOperationException.class, failure.getCause(), + "the existing SDK legacy-save contract must not become a durable-success acknowledgement"); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile()) + .getInt("VotingPlugin.VoteParty.Total")); + fixture.fence.retire(); + assertThrows(IllegalStateException.class, () -> + ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath())); + } + } + + @Test void cancelledObservationStillRefreshesAfterTheAsynchronousOwnersActualCommit() throws Exception { + class DeferredOwner extends com.bencodez.advancedcore.data.ServerData { + final java.util.concurrent.CompletableFuture publication = new java.util.concurrent.CompletableFuture<>(); + java.util.function.Consumer mutation; + DeferredOwner(VotingPluginMain plugin) { super(plugin); } + @Override public java.util.concurrent.CompletionStage mutateFinalData( + java.util.function.Consumer operation) { + mutation = operation; + return publication; + } + void publish() { + // Defer the real native mutation and durable publication, rather than mocking their result. + super.mutateFinalData(mutation).whenComplete((ignored, failure) -> { + if (failure == null) publication.complete(null); + else publication.completeExceptionally(failure); + }); + } + } + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(DeferredOwner::new)) { + var owner = (DeferredOwner) fixture.backing; + var observation = fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + assertEquals(7, fixture.data.getVotePartyDisplay().total()); + assertFalse(owner.publication.isDone()); + assertTrue(observation.cancel(false)); + assertFalse(owner.publication.isCancelled()); + fixture.fence.retire(); + assertThrows(IllegalStateException.class, () -> + ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath())); + owner.publish(); + owner.publication.get(5, TimeUnit.SECONDS); + assertTrue(observation.isCancelled()); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile()) + .getInt("VotingPlugin.VoteParty.Total")); + var successor = ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()); + successor.retire(); + } + } + + @Test void finalPublicationWithoutADisplayWrapperDoesNotAddYamlObservation() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + var backing = mock(com.bencodez.advancedcore.data.ServerData.class); + var publication = new java.util.concurrent.CompletableFuture(); + when(backing.mutateFinalData(any())).thenReturn(publication); + when(backing.publicationMonitor()).thenThrow(new AssertionError("No display owner to observe")); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + try { + var observation = facade.mutateFinalData(data -> {}).toCompletableFuture(); + fence.retire(); + assertFalse(observation.isDone()); + assertThrows(IllegalStateException.class, () -> + ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml"))); + publication.complete(null); + observation.get(5, TimeUnit.SECONDS); + verify(backing, never()).publicationMonitor(); + verify(backing, never()).getData(); + var successor = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + successor.retire(); + } finally { fence.retire(); } + } + } + + @Test void publicOrdinaryMutationOwnsYamlAndRefreshesBeforeItsSave() throws Exception { + var display = new java.util.concurrent.atomic.AtomicReference(); + var saves = new java.util.concurrent.atomic.AtomicInteger(); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void saveData() { + assertTrue(Thread.holdsLock(publicationMonitor())); + assertEquals(17, display.get().getVotePartyDisplay().total()); + saves.incrementAndGet(); + super.saveData(); + } + })) { + display.set(fixture.data); + fixture.exposed.mutateOrdinaryData(live -> { + assertTrue(Thread.holdsLock(fixture.backing.publicationMonitor()), + "the newly public inherited entry must acquire the actual YAML owner before mutation"); + assertSame(fixture.yaml, live); + live.set("VotingPlugin.VoteParty.Total", 17); + }); + assertEquals(1, saves.get()); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertSame(fixture.yaml, fixture.backing.getData()); + assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile()) + .getInt("VotingPlugin.VoteParty.Total")); + } + } + + @Test void ordinaryCallbackFailureKeepsLiveDisplayAndOriginalFailureWithoutSaving() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) { + var failure = new IllegalArgumentException("Synthetic ordinary callback failure"); + assertSame(failure, assertThrows(IllegalArgumentException.class, () -> + fixture.exposed.mutateOrdinaryData(live -> { + live.set("VotingPlugin.VoteParty.Total", 17); + throw failure; + }))); + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath()), + "a failed callback must not run an ordinary save or claim rollback"); + } + } + + @Test void sdkNullRemovalAndSectionCreationRefreshTheSameLiveYaml() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) { + fixture.exposed.setData("VotingPlugin.VoteParty.Total", null); + assertFalse(fixture.yaml.contains("VotingPlugin.VoteParty.Total")); + assertEquals(0, fixture.data.getVotePartyDisplay().total()); + fixture.exposed.setValue("VotingPlugin.VoteParty.Total", 11); + assertEquals(11, fixture.data.getVotePartyDisplay().total()); + fixture.exposed.createSection("VotingPlugin.VoteParty"); + assertNotNull(fixture.yaml.getConfigurationSection("VotingPlugin.VoteParty")); + assertEquals(0, fixture.data.getVotePartyDisplay().total()); + assertEquals(3, fixture.data.getVotePartyDisplay().extraRequired()); + assertSame(fixture.yaml, fixture.backing.getData()); + assertFalse(YamlConfiguration.loadConfiguration(fixture.backing.getdFile()) + .contains("VotingPlugin.VoteParty.Total")); + } + } + + @Test void retiredOrdinaryEntryRejectsBeforeInvokingTheCallback() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) { + fixture.fence.retire(); + var successor = ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath()); + try { + var called = new AtomicBoolean(); + assertThrows(IllegalStateException.class, () -> + fixture.exposed.mutateOrdinaryData(live -> called.set(true))); + assertFalse(called.get(), "a stale facade must reject before caller effects"); + assertEquals(7, fixture.yaml.getInt("VotingPlugin.VoteParty.Total")); + assertEquals(7, fixture.data.getVotePartyDisplay().total()); + } finally { successor.retire(); } + } + } + + @Test void sdkSetupReloadsTheLiveDisplayFromTheExistingFile() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) { + var replacement = new YamlConfiguration(); + replacement.set("VotingPlugin.VoteParty.Total", 29); + replacement.set("VotingPlugin.VotePartyExtraRequired", 4); + replacement.save(fixture.backing.getdFile()); + byte[] before = java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath()); + fixture.exposed.setup(); + assertEquals(29, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(29, fixture.data.getVotePartyDisplay().total()); + assertEquals(4, fixture.data.getVotePartyDisplay().extraRequired()); + assertArrayEquals(before, java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath())); + } + } + + @Test void sdkCaseModeChangesRefreshWithoutSavingOrRewritingKeys() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(com.bencodez.advancedcore.data.ServerData::new)) { + synchronized (fixture.backing.publicationMonitor()) { + fixture.yaml.set("VotingPlugin", null); + fixture.yaml.set("votingplugin.VoteParty.Total", 17); + fixture.yaml.set("votingplugin.VotePartyExtraRequired", 4); + } + fixture.data.refreshVotePartyDisplay(); + assertEquals(0, fixture.data.getVotePartyDisplay().total()); + String original = fixture.yaml.saveToString(); + fixture.exposed.setIgnoreCase(true); + assertEquals(17, fixture.backing.getData().getConfigurationSection("VotingPlugin") + .getInt("VoteParty.Total"), "verify the actual SDK section contract before its display"); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertEquals(4, fixture.data.getVotePartyDisplay().extraRequired()); + fixture.exposed.setIgnoreCase(false); + assertEquals(0, fixture.data.getVotePartyDisplay().total()); + assertSame(fixture.yaml, fixture.backing.getData()); + assertEquals(original, fixture.yaml.saveToString()); + assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath())); + } + } + + @Test void sdkLoadValuesRefreshesWithoutAnOrdinarySave() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void loadValues() { getData().set("VotingPlugin.VoteParty.Total", 17); } + })) { + fixture.exposed.loadValues(); + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath())); + } + } + + @Test void sdkFailedLoadRefreshesLiveValuesAndPreservesFailure() throws Exception { + var failure = new IllegalArgumentException("Synthetic load failure after live mutation"); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void loadValues() { + getData().set("VotingPlugin.VoteParty.Total", 17); + throw failure; + } + })) { + assertSame(failure, assertThrows(IllegalArgumentException.class, fixture.exposed::loadValues)); + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + assertFalse(java.nio.file.Files.exists(fixture.backing.getdFile().toPath())); + } + } + + @Test void sdkLifecycleHooksRemainLazyWhileTheFileIsUninitialized() throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + var backing = new com.bencodez.advancedcore.data.ServerData(plugin); + var fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var facade = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + when(plugin.getServerDataFile()).thenReturn(facade); + var display = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(display); + try { + facade.loadValues(); + facade.onFileCreation(); + facade.setIgnoreCase(true); + assertNull(backing.getData()); + assertEquals(0, display.getVotePartyDisplay().total()); + assertFalse(java.nio.file.Files.exists(backing.getdFile().toPath())); + verify(plugin, never()).getServerDataFile(); + } finally { fence.retire(); } + } + } + + @Test void sdkFailedLoadKeepsItsPrimaryWhenObservationAlsoFails() throws Exception { + var primary = new IllegalArgumentException("Synthetic primary load failure"); + var secondary = new IllegalStateException("Synthetic unavailable live observation"); + var unavailable = new AtomicBoolean(); + var observations = new java.util.concurrent.atomic.AtomicInteger(); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public org.bukkit.configuration.file.FileConfiguration getData() { + if (unavailable.get()) { observations.incrementAndGet(); throw secondary; } + return super.getData(); + } + @Override public void loadValues() { + super.getData().set("VotingPlugin.VoteParty.Total", 17); + unavailable.set(true); + throw primary; + } + })) { + try { + assertSame(primary, assertThrows(IllegalArgumentException.class, fixture.exposed::loadValues)); + if (observations.get() > 0) { + assertTrue(java.util.Arrays.asList(primary.getSuppressed()).contains(secondary), + "an attempted observer failure must remain attached to the original load error"); + } + } finally { unavailable.set(false); } + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + fixture.data.refreshVotePartyDisplay(); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + } + } + + @Test void wrapperOrdinarySavesKeepOneOwnerCallDetachedListsAndConditionalNoOp() throws Exception { + var saves = new java.util.concurrent.atomic.AtomicInteger(); + var display = new java.util.concurrent.atomic.AtomicReference(); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public void saveData() { + assertTrue(Thread.holdsLock(publicationMonitor())); + assertEquals(getData().getInt("VotingPlugin.VoteParty.Total"), + display.get().getVotePartyDisplay().total()); + saves.incrementAndGet(); + super.saveData(); + } + })) { + display.set(fixture.data); + fixture.data.setInt("VoteParty.Total", 17); + assertEquals(1, saves.get()); + var values = new java.util.ArrayList<>(java.util.List.of("FIRST", "SECOND")); + fixture.data.setStringList("AutoCachePlaceholders", values); + values.add("later"); + assertEquals(2, saves.get()); + assertEquals(java.util.List.of("FIRST", "SECOND"), fixture.data.getAutoCachedPlaceholder()); + fixture.data.updatePlaceholders(); + assertEquals(3, saves.get(), "a compound ordinary update saves only after its complete admitted mutation"); + assertEquals(java.util.List.of("first", "second"), fixture.data.getAutoCachedPlaceholder()); + assertTrue(fixture.data.getData().getBoolean("AutoCacheUpdated")); + fixture.data.updatePlaceholders(); + assertEquals(3, saves.get(), "the established already-updated no-op must not save"); + fixture.data.setVotePartyExtraRequired(5); + assertEquals(4, saves.get()); + assertEquals(5, fixture.data.getVotePartyDisplay().extraRequired()); + assertEquals(17, YamlConfiguration.loadConfiguration(fixture.backing.getdFile()) + .getInt("VotingPlugin.VoteParty.Total")); + } + } + + @Test void wrapperIntRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception { + assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setInt("VoteParty.Total", 23)); + } + + @Test void wrapperListRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception { + assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setStringList("SyntheticList", java.util.List.of("entry"))); + } + + @Test void wrapperExtraRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception { + assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setVotePartyExtraRequired(9)); + } + + @Test void wrapperShopConversionRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception { + assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setShopConverted(true)); + } + + @Test void wrapperShopPurchaseRejectsBeforeMutationAfterUnconfirmedNativeCommit() throws Exception { + assertWrapperRejectsAfterUnconfirmedCommit(data -> data.setVoteShopPurchases("synthetic", 4)); + } + + private void assertWrapperRejectsAfterUnconfirmedCommit(java.util.function.Consumer mutation) + throws Exception { + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override protected void forceDirectory(Path path) throws java.io.IOException { + if (java.nio.file.Files.exists(getdFile().toPath())) + throw new java.io.IOException("Synthetic post-replacement directory-force failure"); + super.forceDirectory(path); + } + })) { + var publication = fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> publication.get(5, TimeUnit.SECONDS)); + assertInstanceOf(java.io.UncheckedIOException.class, failure.getCause()); + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total"), + "the real replacement became visible before durability remained unconfirmed"); + assertEquals(17, fixture.data.getVotePartyDisplay().total()); + var liveBefore = fixture.backing.getData().saveToString(); + var diskBefore = java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath()); + var displayBefore = fixture.data.getVotePartyDisplay(); + assertThrows(IllegalStateException.class, () -> mutation.accept(fixture.data)); + assertEquals(liveBefore, fixture.backing.getData().saveToString(), + "SDK rejection must precede wrapper YAML mutation"); + assertEquals(displayBefore, fixture.data.getVotePartyDisplay(), + "a rejected mutation cannot advance the live projection"); + assertArrayEquals(diskBefore, java.nio.file.Files.readAllBytes(fixture.backing.getdFile().toPath())); + fixture.fence.retire(); + assertThrows(IllegalStateException.class, () -> + ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath())); + } + } + + @Test void synchronousFinalOwnerThrowAfterCommitRefreshesLiveDisplayAndRetainsFailure() throws Exception { + var primary = new IllegalStateException("Synthetic post-commit owner cleanup failure"); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public java.util.concurrent.CompletionStage mutateFinalData( + java.util.function.Consumer mutation) { + var candidate = new YamlConfiguration(); + mutation.accept(candidate); + assertEquals(17, candidate.getInt("VotingPlugin.VoteParty.Total")); + getData().set("VotingPlugin.VoteParty.Total", 17); + throw primary; + } + })) { + var result = fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + assertSame(primary, assertThrows(java.util.concurrent.ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS)).getCause()); + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(17, fixture.data.getVotePartyDisplay().total(), + "a synchronous final-owner throw must still observe its actual committed YAML"); + fixture.fence.retire(); + assertThrows(IllegalStateException.class, () -> + ServerDataPublicationFence.beginGeneration(fixture.backing.getdFile().toPath())); + } + } + + @Test void synchronousFinalOwnerThrowBeforeCommitKeepsPredecessorDisplay() throws Exception { + var primary = new IllegalArgumentException("Synthetic owner rejection before commit"); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public java.util.concurrent.CompletionStage mutateFinalData( + java.util.function.Consumer mutation) { + var candidate = new YamlConfiguration(); + mutation.accept(candidate); + throw primary; + } + })) { + var result = fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + assertSame(primary, assertThrows(java.util.concurrent.ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS)).getCause()); + assertEquals(7, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + assertEquals(7, fixture.data.getVotePartyDisplay().total()); + } + } + + @Test void synchronousFinalOwnerThrowKeepsPrimaryAndSuppressesObservationFailure() throws Exception { + var primary = new IllegalArgumentException("Synthetic committed owner cleanup failure"); + var secondary = new IllegalStateException("Synthetic unavailable live observation"); + var unavailable = new AtomicBoolean(); + try (var schedulers = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + var fixture = new ProjectionFixture(plugin -> new com.bencodez.advancedcore.data.ServerData(plugin) { + @Override public org.bukkit.configuration.file.FileConfiguration getData() { + if (unavailable.get()) throw secondary; + return super.getData(); + } + @Override public java.util.concurrent.CompletionStage mutateFinalData( + java.util.function.Consumer mutation) { + var candidate = new YamlConfiguration(); + mutation.accept(candidate); + super.getData().set("VotingPlugin.VoteParty.Total", 17); + unavailable.set(true); + throw primary; + } + })) { + try { + var result = fixture.exposed.mutateFinalData(candidate -> + candidate.set("VotingPlugin.VoteParty.Total", 17)).toCompletableFuture(); + assertSame(primary, assertThrows(java.util.concurrent.ExecutionException.class, + () -> result.get(5, TimeUnit.SECONDS)).getCause()); + assertTrue(java.util.Arrays.asList(primary.getSuppressed()).contains(secondary)); + } finally { unavailable.set(false); } + assertEquals(17, fixture.backing.getData().getInt("VotingPlugin.VoteParty.Total")); + } + } + + private final class ProjectionFixture implements AutoCloseable { + final com.bencodez.advancedcore.data.ServerData backing; + final YamlConfiguration yaml = new YamlConfiguration(); + final ServerDataPublicationFence fence; + final GenerationFencedServerDataFile exposed; + final ServerData data; + ProjectionFixture(java.util.function.Function factory) { + var plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + backing = factory.apply(plugin); + yaml.set("VotingPlugin.VoteParty.Total", 7); + yaml.set("VotingPlugin.VotePartyExtraRequired", 3); + backing.setData(yaml); + fence = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + exposed = new GenerationFencedServerDataFile(plugin, backing, fence); + when(plugin.getServerDataPublicationFence()).thenReturn(fence); + when(plugin.getServerDataFile()).thenReturn(exposed); + data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + data.refreshVotePartyDisplay(); + } + @Override public void close() { fence.retire(); } + } + + private static String value(PlaceHolders placeholders, String identifier) { + return placeholders.getNonPlayerPlaceholders().stream().filter(p -> p.matches(identifier)) + .findFirst().orElseThrow().placeholderRequest(identifier); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueCheckpointRetryTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueCheckpointRetryTest.java new file mode 100644 index 0000000000..f6e7e5bf6c --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueCheckpointRetryTest.java @@ -0,0 +1,78 @@ +package com.bencodez.votingplugin.tests.timequeue; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; + +class TimeQueueCheckpointRetryTest { + @Test void repeatedStorageFailuresBackOffAndRepairResumesTheSameOccurrence() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ServerData data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + when(data.getTimedVoteCacheKeys()).thenReturn(Set.of()); + var timer = plugin.getVoteTimer(); + var bukkit = plugin.getBukkitScheduler(); + List attempts = new ArrayList<>(), retries = new ArrayList<>(); + List seconds = new ArrayList<>(); + doAnswer(call -> { attempts.add(call.getArgument(0)); return null; }) + .when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + doAnswer(call -> { retries.add(call.getArgument(1)); seconds.add(call.getArgument(2)); return null; }) + .when(bukkit).runTaskLaterAsynchronously(eq(plugin), any(Runnable.class), anyLong()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + handler.addVote("First", "site"); + doThrow(new IllegalStateException("synthetic write failure")) + .doThrow(new IllegalStateException("synthetic write failure")) + .doNothing().when(data).prepareTimedVoteDispatch(any(), any(), any()); + assertThrows(IllegalStateException.class, attempts.getFirst()::run); + var id = handler.getTimeChangeQueue().peek().getVoteId(); + retries.getFirst().run(); + assertThrows(IllegalStateException.class, attempts.get(1)::run); + assertEquals(List.of(1L, 2L), seconds); + assertEquals(id, handler.getTimeChangeQueue().peek().getVoteId()); + retries.get(1).run(); + attempts.get(2).run(); + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(plugin.getServer().getPluginManager(), times(1)).callEvent(any()); + } + + @Test void scheduledPreparationAndCompletionFailuresEachRetryWithoutRepeatingDeliveredEvents() { + for (String phase : List.of("prepare", "complete")) { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ServerData data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + when(data.getTimedVoteCacheKeys()).thenReturn(Set.of()); + List scheduled = new ArrayList<>(); + var timer = plugin.getVoteTimer(); + doAnswer(call -> { scheduled.add(call.getArgument(0)); return null; }) + .when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + handler.addVote("First", "site"); + handler.addVote("Later", "site"); + if (phase.equals("prepare")) doThrow(new IllegalStateException("synthetic write failure")) + .doNothing().when(data).prepareTimedVoteDispatch(any(), any(), any()); + else doThrow(new IllegalStateException("synthetic write failure")) + .doNothing().when(data).completeTimedVoteDispatch(any()); + assertThrows(IllegalStateException.class, scheduled.getFirst()::run); + var retry = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getBukkitScheduler(), times(1)).runTaskLaterAsynchronously(eq(plugin), retry.capture(), eq(1L)); + retry.getValue().run(); + assertEquals(2, scheduled.size()); + scheduled.get(1).run(); + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(plugin.getServer().getPluginManager(), times(2)).callEvent(any()); + if (phase.equals("complete")) verify(data, times(1)).addUncertainTimedVote(any(), + argThat(vote -> vote.getName().equals("First"))); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerCancellationTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerCancellationTest.java index 918eceb118..0dc455f96d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerCancellationTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerCancellationTest.java @@ -104,7 +104,9 @@ void cancelledFirstVoteDoesNotStrandAcceptedVoteInScheduledDrain() { verify(acceptedUser).addTotalDaily(); verify(acceptedUser).addTotalWeekly(); verify(acceptedUser).setTime(site, 456L); - verify(plugin.getServerData()).clearTimedVoteCache(); + verify(plugin.getServerData(), never()).clearTimedVoteCache(); + verify(plugin.getServerData(), times(2)).prepareTimedVoteDispatch(any(), any(), any()); + verify(plugin.getServerData(), times(2)).completeTimedVoteDispatch(any()); queue.processQueue(); assertEquals(2, dispatched.size(), "a subsequent drain cannot dispatch either retired entry again"); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java index 5f5618348f..5833e20f1a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueHandlerRejectionTest.java @@ -99,7 +99,7 @@ void rejectedProcessingSchedulesOneBoundedRetry() { TimeQueueHandler handler = new TimeQueueHandler(plugin); org.mockito.ArgumentCaptor retry = org.mockito.ArgumentCaptor.forClass(Runnable.class); verify(plugin.getBukkitScheduler()).runTaskLaterAsynchronously( - org.mockito.ArgumentMatchers.eq(plugin), retry.capture(), org.mockito.ArgumentMatchers.eq(20L)); + org.mockito.ArgumentMatchers.eq(plugin), retry.capture(), org.mockito.ArgumentMatchers.eq(1L)); reset(voteTimer); retry.getValue().run(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java new file mode 100644 index 0000000000..9bc6abe6b8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueRecoveryCheckpointTest.java @@ -0,0 +1,392 @@ +package com.bencodez.votingplugin.tests.timequeue; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.events.PlayerVoteEvent; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; + +/** Real ServerData/YAML writes and restart loading; mock only Bukkit services and its file adapter. */ +class TimeQueueRecoveryCheckpointTest { + @TempDir Path directory; + + @Test + void stoppingDuringPreparationRestoresTheUndispatchedVoteForActualRestartRecovery() throws Exception { + Fixture fixture = new Fixture(directory.resolve("aborted-preparation.yml")); + fixture.handler.addVote("First", "site"); + fixture.handler.addVote("Later", "site"); + fixture.handler.save(); + var entered = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + doAnswer(call -> { + call.callRealMethod(); + entered.countDown(); + assertTrue(release.await(10, java.util.concurrent.TimeUnit.SECONDS)); + return null; + }).when(fixture.yaml).save(fixture.path.toFile()); + var callers = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var delivery = callers.submit(fixture.handler::processQueue); + assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + callers.submit(fixture.handler::saveForShutdown).get(2, java.util.concurrent.TimeUnit.SECONDS); + release.countDown(); + delivery.get(5, java.util.concurrent.TimeUnit.SECONDS); + fixture.handler.saveForShutdown(); + verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any()); + assertEquals(List.of("First", "Later"), new Fixture(fixture.path).names()); + assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty()); + assertEquals("keep", fixture.stored().getString("Adjacent.Value")); + } finally { + release.countDown(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)); + fixture.handler.saveForShutdown(); + } + } + + @Test + void failedLateAdmissionRemainsRetryableAfterTheEarlierShutdownSaveSucceeded() throws Exception { + Fixture fixture = new Fixture(directory.resolve("late-admission.yml")); + fixture.handler.addVote("First", "site"); + fixture.handler.saveForShutdown(); + Path saved = fixture.blockWrites(); + assertThrows(java.io.UncheckedIOException.class, () -> fixture.handler.addVote("Late", "site")); + assertEquals(List.of("Late"), fixture.names()); + fixture.repair(saved); + fixture.handler.saveForShutdown(); + assertTrue(fixture.names().isEmpty()); + assertEquals(List.of("First", "Late"), new Fixture(fixture.path).names()); + assertEquals("keep", fixture.stored().getString("Adjacent.Value")); + } + + @Test + void abortedPreparationWriteFailurePreservesBothWrappersUntilRepairAndRetry() throws Exception { + Fixture fixture = new Fixture(directory.resolve("abort-retry.yml")); + VoteTimeQueue first = new VoteTimeQueue("First", "site", 123L); + VoteTimeQueue later = new VoteTimeQueue("Later", "site", 456L); + UUID id = new UUID(1, 2); + fixture.data.prepareTimedVoteDispatch(id, first, List.of(later)); + Path saved = fixture.blockWrites(); + assertThrows(java.io.UncheckedIOException.class, + () -> fixture.data.abortTimedVoteDispatch(id, List.of(first, later))); + assertEquals("Later", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals("First", fixture.yaml.getString("VotingPlugin.UncertainTimedVotes." + id + ".Name")); + fixture.repair(saved); + fixture.data.abortTimedVoteDispatch(id, List.of(first, later)); + assertEquals(List.of("First", "Later"), new Fixture(fixture.path).names()); + assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty()); + assertEquals("keep", fixture.stored().getString("Adjacent.Value")); + } + + @Test + void cancelledDispatchKeepsSuffixRecoverableWhenNextPreparationFails() throws Exception { + Fixture fixture = new Fixture(directory.resolve("ServerData.yml")); + fixture.handler.addVote("First", "site"); + fixture.handler.addVote("Later", "site"); + fixture.handler.save(); + assertEquals(2, fixture.handler.getTimeChangeQueue().size()); + AtomicInteger deliveries = new AtomicInteger(); + ServerData data = spy(fixture.data); + when(fixture.plugin.getServerData()).thenReturn(data); + AtomicInteger preparations = new AtomicInteger(); + doAnswer(call -> { + if (preparations.incrementAndGet() == 2) throw new IllegalStateException("synthetic next-checkpoint failure"); + return call.callRealMethod(); + }).when(data).prepareTimedVoteDispatch(any(), any(), any()); + var events = fixture.plugin.getServer().getPluginManager(); + doAnswer(call -> { + PlayerVoteEvent event = call.getArgument(0); + YamlConfiguration during = fixture.stored(); + assertEquals("Later", during.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals(1, during.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).size()); + deliveries.incrementAndGet(); + fixture.handler.addVote("LateAdmission", "site"); + event.setCancelled(true); + return null; + }).when(events).callEvent(any()); + assertThrows(IllegalStateException.class, fixture.handler::processQueue); + assertEquals(2, preparations.get(), "cancellation must continue to the next pending occurrence"); + assertEquals(1, deliveries.get()); + assertEquals(List.of("Later", "LateAdmission"), fixture.handler.getTimeChangeQueue().stream().map(v -> v.getName()).toList()); + YamlConfiguration after = fixture.stored(); + assertEquals("Later", after.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertTrue(after.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty()); + Fixture restarted = new Fixture(fixture.path); + assertEquals(List.of("Later", "LateAdmission"), restarted.handler.getTimeChangeQueue().stream().map(v -> v.getName()).toList()); + assertEquals("keep", after.getString("Adjacent.Value")); + } + + @Test + void cancelledDispatchContinuesThroughLateAdmissionsWithRealDurableCheckpoints() throws Exception { + Fixture fixture = new Fixture(directory.resolve("cancel-and-drain.yml")); + fixture.handler.addVote("Cancelled", "site"); + fixture.handler.addVote("Accepted", "site"); + fixture.handler.save(); + var delivered = new java.util.ArrayList(); + var events = fixture.plugin.getServer().getPluginManager(); + doAnswer(call -> { + PlayerVoteEvent event = call.getArgument(0); + delivered.add(event.getPlayer()); + var uncertain = fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes"); + assertEquals(1, uncertain.getKeys(false).size()); + String id = uncertain.getKeys(false).iterator().next(); + assertEquals(event.getPlayer(), uncertain.getString(id + ".Name")); + if (event.getPlayer().equals("Cancelled")) { + fixture.handler.addVote("LateAdmission", "site"); + event.setCancelled(true); + } + return null; + }).when(events).callEvent(any()); + fixture.handler.processQueue(); + assertEquals(List.of("Cancelled", "Accepted", "LateAdmission"), delivered); + assertTrue(fixture.names().isEmpty()); + assertTrue(new Fixture(fixture.path).names().isEmpty(), "completed entries must not return after restart"); + assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty()); + assertEquals("keep", fixture.stored().getString("Adjacent.Value")); + fixture.handler.processQueue(); + assertEquals(3, delivered.size(), "another drain must not repeat a completed occurrence"); + } + + @Test + void preparationWriteFailureKeepsTheOriginalLiveAndDiskSuffixWithoutDispatch() throws Exception { + Fixture fixture = new Fixture(directory.resolve("ServerData.yml")); + fixture.handler.addVote("First", "site"); + fixture.handler.addVote("Later", "site"); + fixture.handler.save(); + Path saved = fixture.blockWrites(); + assertThrows(java.io.UncheckedIOException.class, fixture.handler::processQueue); + verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any()); + assertEquals(List.of("First", "Later"), fixture.names()); + assertEquals("First", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name")); + UUID originalId = fixture.handler.getTimeChangeQueue().peek().getVoteId(); + fixture.repair(saved); + var events = fixture.plugin.getServer().getPluginManager(); + var seen = new java.util.ArrayList(); + doAnswer(call -> { + seen.addAll(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes") + .getKeys(false).stream().map(UUID::fromString).toList()); + return null; + }).when(events).callEvent(any()); + fixture.handler.processQueue(); + assertEquals(originalId, seen.getFirst()); + assertEquals(2, seen.size()); + assertTrue(fixture.names().isEmpty()); + assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.TimedVoteCache").getKeys(false).isEmpty()); + } + + @Test + void successfullyQuarantinedExceptionAndErrorEachAllowTheUnaffectedSuffixToResume() throws Exception { + for (Throwable original : List.of(new IllegalStateException("synthetic event failure"), + new AssertionError("synthetic event failure"))) { + Fixture fixture = new Fixture(directory.resolve(original.getClass().getSimpleName() + ".yml")); + fixture.handler.addVote("Ambiguous", "site"); + fixture.handler.addVote("Later", "site"); + AtomicInteger calls = new AtomicInteger(); + var events = fixture.plugin.getServer().getPluginManager(); + doAnswer(call -> { + if (calls.incrementAndGet() == 1) { fixture.handler.addVote("LateAdmission", "site"); throw original; } + return null; + }) + .when(events).callEvent(any()); + assertSame(original, assertThrows(original.getClass(), fixture.handler::processQueue)); + assertEquals(List.of("Later", "LateAdmission"), fixture.names()); + Fixture restarted = new Fixture(fixture.path); + assertEquals(List.of("Later", "LateAdmission"), restarted.names(), "ambiguous occurrence must not be replayable"); + fixture.handler.processQueue(); + assertEquals(3, calls.get()); + assertTrue(fixture.names().isEmpty()); + assertEquals(1, fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes") + .getKeys(false).size()); + } + } + + @Test + void preparationErrorAfterTheFileWasWrittenDoesNotAuthorizeAnEventOrLoseItsIdentity() throws Exception { + Fixture fixture = new Fixture(directory.resolve("ServerData.yml")); + fixture.handler.addVote("First", "site"); + fixture.handler.addVote("Later", "site"); + fixture.handler.save(); + doAnswer(call -> { + call.callRealMethod(); + throw new java.io.IOException("synthetic failure after file publication"); + }).when(fixture.yaml).save(fixture.path.toFile()); + assertThrows(java.io.UncheckedIOException.class, fixture.handler::processQueue); + verify(fixture.plugin.getServer().getPluginManager(), never()).callEvent(any()); + UUID id = fixture.handler.getTimeChangeQueue().peek().getVoteId(); + assertEquals(List.of("First", "Later"), fixture.names()); + assertEquals("First", fixture.yaml.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals("First", fixture.stored().getString("VotingPlugin.UncertainTimedVotes." + id + ".Name")); + assertEquals(List.of("Later"), new Fixture(fixture.path).names()); + doCallRealMethod().when(fixture.yaml).save(fixture.path.toFile()); + var events = fixture.plugin.getServer().getPluginManager(); + AtomicInteger calls = new AtomicInteger(); + doAnswer(call -> { + if (calls.incrementAndGet() == 1) + assertEquals("First", fixture.stored().getString("VotingPlugin.UncertainTimedVotes." + id + ".Name")); + return null; + }).when(events).callEvent(any()); + fixture.handler.processQueue(); + assertEquals(2, calls.get()); + assertTrue(fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false).isEmpty()); + } + + @Test + void failedQuarantineRetriesItsSameIdentityWithoutRepeatingTheEvent() throws Exception { + Fixture fixture = new Fixture(directory.resolve("ServerData.yml")); + fixture.handler.addVote("Ambiguous", "site"); + fixture.handler.addVote("Later", "site"); + AtomicInteger calls = new AtomicInteger(); + var original = new IllegalStateException("synthetic event failure"); + var saved = new java.util.concurrent.atomic.AtomicReference(); + var events = fixture.plugin.getServer().getPluginManager(); + doAnswer(call -> { + if (calls.incrementAndGet() == 1) { saved.set(fixture.blockWrites()); throw original; } + return null; + }).when(events).callEvent(any()); + assertSame(original, assertThrows(IllegalStateException.class, fixture.handler::processQueue)); + assertEquals(1, original.getSuppressed().length); + assertEquals(List.of("Ambiguous", "Later"), fixture.names()); + UUID id = fixture.handler.getTimeChangeQueue().peek().getVoteId(); + fixture.repair(saved.get()); + fixture.handler.processQueue(); + assertEquals(2, calls.get()); + assertTrue(fixture.names().isEmpty()); + assertEquals(java.util.Set.of(id.toString()), fixture.stored() + .getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false)); + assertTrue(new Fixture(fixture.path).names().isEmpty()); + } + + @Test + void failedCompletionPublicationKeepsTheDeliveredOccurrenceOutOfAutomaticRecovery() throws Exception { + Fixture fixture = new Fixture(directory.resolve("ServerData.yml")); + fixture.handler.addVote("Delivered", "site"); + fixture.handler.addVote("Later", "site"); + AtomicInteger calls = new AtomicInteger(); + var saved = new java.util.concurrent.atomic.AtomicReference(); + var events = fixture.plugin.getServer().getPluginManager(); + doAnswer(call -> { if (calls.incrementAndGet() == 1) saved.set(fixture.blockWrites()); return null; }) + .when(events).callEvent(any()); + assertThrows(java.io.UncheckedIOException.class, fixture.handler::processQueue); + UUID id = fixture.handler.getTimeChangeQueue().peek().getVoteId(); + assertEquals("Delivered", fixture.yaml.getString("VotingPlugin.UncertainTimedVotes." + id + ".Name")); + fixture.repair(saved.get()); + assertEquals(List.of("Later"), new Fixture(fixture.path).names()); + fixture.handler.processQueue(); + assertEquals(2, calls.get(), "successful event must not run again after its completion save failed"); + assertTrue(fixture.names().isEmpty()); + assertEquals(java.util.Set.of(id.toString()), fixture.stored() + .getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false)); + } + + @Test + void shutdownDuringARealInFlightDispatchRetainsTheSuffixWithoutWaitingForTheEvent() throws Exception { + Fixture fixture = new Fixture(directory.resolve("ServerData.yml")); + fixture.handler.addVote("InFlight", "site"); + fixture.handler.addVote("Later", "site"); + var entered = new java.util.concurrent.CountDownLatch(1); + var release = new java.util.concurrent.CountDownLatch(1); + var events = fixture.plugin.getServer().getPluginManager(); + doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, java.util.concurrent.TimeUnit.SECONDS)); return null; }) + .when(events).callEvent(any()); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var delivery = worker.submit(fixture.handler::processQueue); + assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + fixture.handler.saveForShutdown(); + assertEquals(List.of("Later"), new Fixture(fixture.path).names()); + assertEquals(1, fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes") + .getKeys(false).size()); + assertFalse(delivery.isDone()); + release.countDown(); delivery.get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(events, times(1)).callEvent(any()); + assertEquals(1, fixture.stored().getConfigurationSection("VotingPlugin.UncertainTimedVotes") + .getKeys(false).size(), "late success must preserve the conservative shutdown record"); + } finally { + release.countDown(); worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + + @Test + void completeYamlWrappersKeepLiteralUnicodeAndAdjacentRecordsAcrossLfAndCrLf() throws Exception { + for (String newline : List.of("\n", "\r\n")) { + Path path = directory.resolve(newline.length() == 1 ? "lf.yml" : "crlf.yml"); + UUID other = new UUID(1, 1), current = new UUID(1, 2); + Files.writeString(path, ("Adjacent: {Value: 'keep'}\nVotingPlugin:\n" + + " Other: {Literal: '%player% [Javascript=1] ๐ŸŽ‰'}\n" + + " TimedVoteCache:\n legacy: {Name: 'First', Service: 'Fรชte ๐ŸŽ‰', Time: 123}\n" + + " UncertainTimedVotes:\n '" + other + "': {Name: 'Old', Service: 'site', Time: 1}\n") + .replace("\n", newline)); + Fixture fixture = new Fixture(path); + var first = new VoteTimeQueue(current, "First", "%player% [Javascript=1] ๐ŸŽ‰", 123L); + var later = new VoteTimeQueue("Later", "Fรชte ๐ŸŽ‰", 456L); + fixture.data.prepareTimedVoteDispatch(current, first, List.of(later)); + YamlConfiguration stored = fixture.stored(); + assertEquals(first.getService(), stored.getString("VotingPlugin.UncertainTimedVotes." + current + ".Service")); + assertEquals("Fรชte ๐ŸŽ‰", stored.getString("VotingPlugin.TimedVoteCache.0.Service")); + assertEquals(456L, stored.getLong("VotingPlugin.TimedVoteCache.0.Time")); + fixture.data.completeTimedVoteDispatch(current); + stored = fixture.stored(); + assertEquals(java.util.Set.of(other.toString()), stored.getConfigurationSection("VotingPlugin.UncertainTimedVotes").getKeys(false)); + assertEquals("%player% [Javascript=1] ๐ŸŽ‰", stored.getString("VotingPlugin.Other.Literal")); + assertEquals("keep", stored.getString("Adjacent.Value")); + } + } + + private static final class Fixture { + final Path path; + final VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + final YamlConfiguration yaml; + final ServerData data; + final TimeQueueHandler handler; + + Fixture(Path path) throws Exception { + this.path = path; + yaml = spy(java.nio.file.Files.exists(path) ? YamlConfiguration.loadConfiguration(path.toFile()) + : new YamlConfiguration()); + yaml.set("Adjacent.Value", "keep"); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(file.getData()).thenReturn(yaml); + when(file.getdFile()).thenReturn(path.toFile()); + when(plugin.getServerDataFile()).thenReturn(file); + data = new ServerData(plugin); + when(plugin.getServerData()).thenReturn(data); + handler = new TimeQueueHandler(plugin); + } + + YamlConfiguration stored() { + return YamlConfiguration.loadConfiguration(path.toFile()); + } + + List names() { + return handler.getTimeChangeQueue().stream().map(v -> v.getName()).toList(); + } + + Path blockWrites() throws Exception { + Path saved = path.resolveSibling(path.getFileName() + ".saved"); + Files.move(path, saved); + Files.createDirectory(path); + return saved; + } + + void repair(Path saved) throws Exception { + Files.delete(path); + Files.move(saved, path); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueSchedulerContractTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueSchedulerContractTest.java new file mode 100644 index 0000000000..2a43d8bd51 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueSchedulerContractTest.java @@ -0,0 +1,34 @@ +package com.bencodez.votingplugin.tests.timequeue; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.simpleapi.folialib.wrapper.task.WrappedTask; + +class TimeQueueSchedulerContractTest { + @Test void installedSimpleApiForwardsAsyncDelayAsSecondsToThePlatformImplementation() throws Exception { + BukkitScheduler scheduler = mock(BukkitScheduler.class, CALLS_REAL_METHODS); + FoliaLib bridge = mock(FoliaLib.class); + ServerImplementation platform = (ServerImplementation) mock( + FoliaLib.class.getMethod("getImpl").getReturnType()); + doReturn(bridge).when(scheduler).getFoliaLib(); + when(bridge.getImpl()).thenAnswer(ignored -> platform); + AtomicBoolean ran = new AtomicBoolean(); + scheduler.runTaskLaterAsynchronously(mock(org.bukkit.plugin.Plugin.class), () -> ran.set(true), 7L); + @SuppressWarnings("unchecked") + var callback = (org.mockito.ArgumentCaptor>) (Object) + org.mockito.ArgumentCaptor.forClass(Consumer.class); + verify(platform).runLaterAsync(callback.capture(), eq(7L), eq(TimeUnit.SECONDS)); + assertFalse(ran.get()); + callback.getValue().accept(mock(WrappedTask.class)); + assertTrue(ran.get()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java new file mode 100644 index 0000000000..3aefcc95a1 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownBoundTest.java @@ -0,0 +1,162 @@ +package com.bencodez.votingplugin.tests.timequeue; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Set; +import java.util.concurrent.*; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; + +class TimeQueueShutdownBoundTest { + @Test void storageFailureAfterTimeoutIsReportedAndCanRetryAtTheSameIndex() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + // Resolve logging before parallel callbacks; avoid lazy deep-stub creation racing the interrupted caller. + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + ServerData data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + when(data.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1), failed = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { + if (fail.getAndSet(false)) { + entered.countDown(); + assertTrue(release.await(10, TimeUnit.SECONDS)); + failed.countDown(); + throw new IllegalStateException("synthetic storage failure"); + } + return null; + }).when(data).addTimeVoted(anyInt(), any()); + handler.addVote("First", "site"); + ExecutorService caller = Executors.newSingleThreadExecutor(); + try { + Future shutdown = caller.submit(handler::saveForShutdown); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + shutdown.get(2, TimeUnit.SECONDS); + release.countDown(); + assertTrue(failed.await(5, TimeUnit.SECONDS)); + verify(plugin.getLogger(), timeout(5000)).warning( + "Timed vote shutdown persistence failed; pending votes remain owned for retry. Recovery is not confirmed."); + assertEquals(1, handler.getTimeChangeQueue().size()); + try { handler.saveForShutdown(); } + catch (IllegalStateException observedCompletion) { + assertEquals("synthetic storage failure", observedCompletion.getMessage()); + handler.saveForShutdown(); + } + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(data, times(2)).addTimeVoted(eq(0), any()); + } finally { + release.countDown(); + caller.shutdownNow(); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void interruptedWaitPreservesTheFlagAndLeavesPersistenceOwned() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + // Resolve logging before parallel callbacks; avoid lazy deep-stub creation racing the interrupted caller. + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + ServerData data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + when(data.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; }) + .when(data).addTimeVoted(anyInt(), any()); + handler.addVote("First", "site"); + try { + Thread.currentThread().interrupt(); + handler.saveForShutdown(); + assertTrue(Thread.currentThread().isInterrupted()); + Thread.interrupted(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertEquals(1, handler.getTimeChangeQueue().size()); + } finally { + Thread.interrupted(); + release.countDown(); + handler.saveForShutdown(); + } + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(data, times(1)).addTimeVoted(eq(0), any()); + } + + @Test void stalledPreparationDoesNotBlockShutdownOrAuthorizeAnEventAfterStopping() throws Exception { + exerciseStalledWrite("prepare"); + } + + @Test void stalledCompletionDoesNotBlockShutdownOrRepeatTheCompletedEvent() throws Exception { + exerciseStalledWrite("complete"); + } + + @Test void stalledShutdownWriteRetainsOneOwnerAndDoesNotBlockTheLifecycleCaller() throws Exception { + exerciseStalledWrite("shutdown"); + } + + private void exerciseStalledWrite(String phase) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + // Resolve logging before parallel callbacks; avoid lazy deep-stub creation racing the interrupted caller. + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + ServerData data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + when(data.getTimedVoteCacheKeys()).thenReturn(Set.of()); + TimeQueueHandler handler = new TimeQueueHandler(plugin); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + org.mockito.stubbing.Answer stalled = call -> { + entered.countDown(); + boolean interrupted = false; + try { + while (true) { + try { assertTrue(release.await(10, TimeUnit.SECONDS)); break; } + catch (InterruptedException ignored) { interrupted = true; } + } + } finally { if (interrupted) Thread.currentThread().interrupt(); } + return null; + }; + if (phase.equals("prepare")) doAnswer(stalled).when(data).prepareTimedVoteDispatch(any(), any(), any()); + if (phase.equals("complete")) doAnswer(stalled).when(data).completeTimedVoteDispatch(any()); + if (phase.equals("shutdown")) doAnswer(stalled).when(data).addTimeVoted(anyInt(), any()); + handler.addVote("First", "site"); + handler.addVote("Later", "site"); + ExecutorService callers = Executors.newFixedThreadPool(3); + try { + Future processing = phase.equals("shutdown") ? null : callers.submit(handler::processQueue); + Future shutdown; + if (processing == null) { + shutdown = callers.submit(handler::saveForShutdown); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + } else { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + shutdown = callers.submit(handler::saveForShutdown); + } + shutdown.get(2, TimeUnit.SECONDS); + // Repeated unload must join the same retained persistence owner. + Future repeated = callers.submit(handler::saveForShutdown); + repeated.get(2, TimeUnit.SECONDS); + assertFalse(handler.getTimeChangeQueue().isEmpty()); + release.countDown(); + if (processing != null) processing.get(5, TimeUnit.SECONDS); + handler.saveForShutdown(); + assertTrue(handler.getTimeChangeQueue().isEmpty()); + verify(plugin.getServer().getPluginManager(), times(phase.equals("complete") ? 1 : 0)).callEvent(any()); + if (phase.equals("prepare")) verify(data, times(1)).abortTimedVoteDispatch(any(), + argThat(pending -> pending.stream().map(vote -> vote.getName()).toList() + .equals(java.util.List.of("First", "Later")))); + else verify(data, times(1)).addTimeVoted(eq(phase.equals("shutdown") ? 1 : 0), + argThat(vote -> vote.getName().equals("Later"))); + if (phase.equals("shutdown")) verify(data, times(1)).addTimeVoted(eq(0), + argThat(vote -> vote.getName().equals("First"))); + } finally { + release.countDown(); + callers.shutdownNow(); + assertTrue(callers.awaitTermination(5, TimeUnit.SECONDS)); + handler.saveForShutdown(); + } + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java new file mode 100644 index 0000000000..05e2ceda73 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/timequeue/TimeQueueShutdownPersistenceTest.java @@ -0,0 +1,192 @@ +package com.bencodez.votingplugin.tests.timequeue; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.*; +import java.util.concurrent.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.bukkit.configuration.file.YamlConfiguration; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.data.ServerData; +import com.bencodez.votingplugin.timequeue.TimeQueueHandler; +import com.bencodez.votingplugin.timequeue.VoteTimeQueue; + +class TimeQueueShutdownPersistenceTest { + private record Fixture(VotingPluginMain plugin, ServerData data, TimeQueueHandler handler) {} + private Fixture fixture() { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + ServerData data = mock(ServerData.class); + when(plugin.getServerData()).thenReturn(data); + when(data.getTimedVoteCacheKeys()).thenReturn(Set.of()); + return new Fixture(plugin, data, new TimeQueueHandler(plugin)); + } + + @Test void failureRetainsOnlyUnsavedSuffixAndRetriesAtSameIndex() { + Fixture f = fixture(); + Map saved = new TreeMap<>(); + java.util.concurrent.atomic.AtomicBoolean fail = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(call -> { + int index = call.getArgument(0); + VoteTimeQueue vote = call.getArgument(1); + if (index == 1 && fail.getAndSet(false)) throw new IllegalStateException("disk unavailable"); + saved.put(index, vote.getName()); return null; + }).when(f.data).addTimeVoted(anyInt(), any()); + f.handler.addVote("First", "site"); f.handler.addVote("Second", "site"); + assertThrows(IllegalStateException.class, f.handler::saveForShutdown); + assertEquals(Map.of(0, "First"), saved); + assertEquals("Second", f.handler.getTimeChangeQueue().peek().getName()); + f.handler.saveForShutdown(); f.handler.saveForShutdown(); + f.handler.addVote("Late", "site"); + assertEquals(Map.of(0, "First", 1, "Second", 2, "Late"), saved); + assertTrue(f.handler.getTimeChangeQueue().isEmpty()); + verify(f.data, times(1)).addTimeVoted(eq(0), any()); + verify(f.data, times(2)).addTimeVoted(eq(1), any()); + } + + @Test void lateAdmissionDuringSaveIsAppendedWithoutBeingClearedOrOverwriting() throws Exception { + Fixture f = fixture(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + Map saved = new ConcurrentHashMap<>(); + doAnswer(call -> { + int index = call.getArgument(0); + if (index == 0) { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); } + saved.put(index, ((VoteTimeQueue) call.getArgument(1)).getName()); return null; + }).when(f.data).addTimeVoted(anyInt(), any()); + f.handler.addVote("First", "site"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future save = workers.submit(f.handler::saveForShutdown); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var adding = new CountDownLatch(1); + Future late = workers.submit(() -> { adding.countDown(); f.handler.addVote("Late", "site"); }); + assertTrue(adding.await(5, TimeUnit.SECONDS)); release.countDown(); + save.get(5, TimeUnit.SECONDS); late.get(5, TimeUnit.SECONDS); + assertEquals(Map.of(0, "First", 1, "Late"), saved); + assertTrue(f.handler.getTimeChangeQueue().isEmpty()); + } finally { release.countDown(); workers.shutdownNow(); assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); } + } + + @Test void admittedProcessingCallbackCannotClearShutdownSnapshot() { + Fixture f = fixture(); + var scheduled = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(f.plugin.getVoteTimer()).schedule(scheduled.capture(), eq(120L), eq(TimeUnit.SECONDS)); + f.handler.addVote("First", "site"); f.handler.saveForShutdown(); + scheduled.getValue().run(); f.handler.processQueue(); + verify(f.data, never()).clearTimedVoteCache(); + verify(f.plugin.getServer().getPluginManager(), never()).callEvent(any()); + } + + @Test void ordinarySaveDoesNotSealFutureProcessing() { + Fixture f = fixture(); + f.handler.addVote("Saved", "site"); f.handler.save(); + f.handler.addVote("New", "site"); f.handler.processQueue(); + verify(f.plugin.getServer().getPluginManager(), times(2)).callEvent(any()); + } + + @Test void shutdownRetainsInFlightEventForReviewWithoutWaitingOrReplaying() throws Exception { + Fixture f = fixture(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var events = f.plugin.getServer().getPluginManager(); + doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; }) + .when(events).callEvent(any()); + f.handler.addVote("InFlight", "site"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future delivery = worker.submit(f.handler::processQueue); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + f.handler.saveForShutdown(); f.handler.saveForShutdown(); + f.handler.processQueue(); + var vote = org.mockito.ArgumentCaptor.forClass(VoteTimeQueue.class); + verify(f.data, times(1)).addUncertainTimedVote(any(UUID.class), vote.capture()); + assertEquals("InFlight", vote.getValue().getName()); + verify(f.data, never()).addTimeVoted(anyInt(), any()); + assertTrue(f.handler.getTimeChangeQueue().isEmpty()); + release.countDown(); delivery.get(5, TimeUnit.SECONDS); + verify(f.plugin.getServer().getPluginManager(), times(1)).callEvent(any()); + } finally { release.countDown(); worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); } + } + + @Test void shutdownBeforeEventDispatchPersistsVoteForNormalRecovery() throws Exception { + Fixture f = fixture(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var sites = f.plugin.getVoteSiteManager(); + doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; }) + .when(sites).getVoteSite(any(), eq(true)); + f.handler.addVote("NotDispatched", "site"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future delivery = worker.submit(f.handler::processQueue); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + f.handler.saveForShutdown(); + release.countDown(); delivery.get(5, TimeUnit.SECONDS); + verify(f.data).addTimeVoted(eq(0), any()); + verify(f.data, never()).addUncertainTimedVote(any(), any()); + verify(f.plugin.getServer().getPluginManager(), never()).callEvent(any()); + } finally { release.countDown(); worker.shutdownNow(); assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); } + } + + @Test void failedEventAndFailedQuarantineRemainRetryableWithoutRepeatingEvent() { + Fixture f = fixture(); + RuntimeException original = new IllegalStateException("event failure"); + var events = f.plugin.getServer().getPluginManager(); + doThrow(original).when(events).callEvent(any()); + doThrow(new IllegalStateException("disk failure")).doNothing() + .when(f.data).addUncertainTimedVote(any(), any()); + f.handler.addVote("Ambiguous", "site"); + assertSame(original, assertThrows(IllegalStateException.class, f.handler::processQueue)); + assertEquals(1, original.getSuppressed().length); + assertEquals(1, f.handler.getTimeChangeQueue().size()); + f.handler.processQueue(); + f.handler.saveForShutdown(); f.handler.saveForShutdown(); + var ids = org.mockito.ArgumentCaptor.forClass(UUID.class); + verify(f.data, times(2)).addUncertainTimedVote(ids.capture(), any()); + assertEquals(ids.getAllValues().get(0), ids.getAllValues().get(1)); + verify(f.plugin.getServer().getPluginManager(), times(1)).callEvent(any()); + verify(f.data, never()).addTimeVoted(anyInt(), any()); + } + + @Test void directProcessingPublishesPendingSuffixBeforeHandingOffRecoveredVote() { + Fixture f = fixture(); + f.handler.addVote("Recovered", "site"); + f.handler.addVote("Later", "site"); + VoteTimeQueue first = f.handler.getTimeChangeQueue().peek(); + f.handler.processQueue(); + var calls = inOrder(f.data, f.plugin.getServer().getPluginManager()); + calls.verify(f.data).prepareTimedVoteDispatch(eq(first.getVoteId()), eq(first), + argThat(pending -> pending.size() == 1 && pending.iterator().next().getName().equals("Later"))); + calls.verify(f.plugin.getServer().getPluginManager()).callEvent(any()); + verify(f.data, never()).clearTimedVoteCache(); + f.handler.addVote("New", "site"); + f.handler.saveForShutdown(); + verify(f.data).addTimeVoted(eq(0), any()); + } + + @TempDir java.nio.file.Path directory; + @Test void actualTimedCacheWriteReportsIoFailureAndSucceedsAfterRepair() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + var file = mock(com.bencodez.advancedcore.data.ServerData.class); + when(file.publicationMonitor()).thenCallRealMethod(); + when(plugin.getServerDataFile()).thenReturn(file); + YamlConfiguration yaml = new YamlConfiguration(); + when(file.getData()).thenReturn(yaml); + java.nio.file.Path target = directory.resolve("ServerData.yml"); + java.nio.file.Files.createDirectory(target); + when(file.getdFile()).thenReturn(target.toFile()); + ServerData data = new ServerData(plugin); + VoteTimeQueue vote = new VoteTimeQueue("First", "site", 123L); + assertThrows(java.io.UncheckedIOException.class, () -> data.addTimeVoted(0, vote)); + java.nio.file.Files.delete(target); + data.addTimeVoted(0, vote); + YamlConfiguration stored = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("First", stored.getString("VotingPlugin.TimedVoteCache.0.Name")); + assertEquals(123L, stored.getLong("VotingPlugin.TimedVoteCache.0.Time")); + UUID uncertain = UUID.randomUUID(); + data.addUncertainTimedVote(uncertain, vote); + stored = YamlConfiguration.loadConfiguration(target.toFile()); + assertEquals("First", stored.getString("VotingPlugin.UncertainTimedVotes." + uncertain + ".Name")); + assertEquals(Set.of("0"), data.getTimedVoteCacheKeys()); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/ServerDataPublicationFenceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/ServerDataPublicationFenceTest.java new file mode 100644 index 0000000000..874d252a37 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/ServerDataPublicationFenceTest.java @@ -0,0 +1,110 @@ +package com.bencodez.votingplugin.tests.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.votingplugin.util.ServerDataPublicationFence; + +class ServerDataPublicationFenceTest { + @TempDir Path directory; + + @Test void activeGenerationAndRetiringLeasesBlockReplacementOnlyInTheirOwnScope() { + Path file = directory.resolve("ServerData.yml"); + var owner = ServerDataPublicationFence.beginGeneration(file); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file)); + var other = ServerDataPublicationFence.beginGeneration(directory.resolve("Other.yml")); + var lease = owner.acquire(); + owner.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file)); + lease.close(); lease.close(); + var successor = ServerDataPublicationFence.beginGeneration(file); + assertThrows(IllegalStateException.class, owner::acquire); + owner.retire(); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file)); + successor.retire(); other.retire(); + } + + @Test void retiredOwnerCanRetryUntilASuccessorActuallyOwnsThePath() { + Path file = directory.resolve("ServerData.yml"); + var owner = ServerDataPublicationFence.beginGeneration(file); + owner.retire(); + try (var retry = owner.acquire()) { + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file)); + } + var successor = ServerDataPublicationFence.beginGeneration(file); + successor.retire(); + assertThrows(IllegalStateException.class, owner::acquire, + "even a stopped successor must fence an older writer"); + } + + @Test void participatingSeparateClassLoadersShareTheActualOwnershipBoundary() throws Exception { + Path file = directory.resolve("ServerData.yml"); + var owner = ServerDataPublicationFence.beginGeneration(file); + var lease = owner.acquire(); + owner.retire(); + try (URLClassLoader loader = new URLClassLoader(new java.net.URL[] { + ServerDataPublicationFence.class.getProtectionDomain().getCodeSource().getLocation() + }, ClassLoader.getPlatformClassLoader())) { + Class isolated = Class.forName(ServerDataPublicationFence.class.getName(), true, loader); + assertNotSame(ServerDataPublicationFence.class, isolated); + var begin = isolated.getMethod("beginGeneration", Path.class); + InvocationTargetException blocked = assertThrows(InvocationTargetException.class, + () -> begin.invoke(null, file)); + assertInstanceOf(IllegalStateException.class, blocked.getCause()); + lease.close(); + Object successor = begin.invoke(null, file); + try (AutoCloseable held = (AutoCloseable) isolated.getMethod("acquire").invoke(successor)) { + assertThrows(IllegalStateException.class, owner::acquire); + } + isolated.getMethod("retire").invoke(successor); + } finally { lease.close(); } + } + + @Test void completeRegistryWrappersRejectMalformedOwnerDataWithoutTouchingAdjacentContent() throws Exception { + Path file = directory.resolve("ร‰vรฉnement ๐ŸŽ‰").resolve("ServerData.yml"); + var owner = ServerDataPublicationFence.beginGeneration(file); + var field = ServerDataPublicationFence.class.getDeclaredField("key"); + field.setAccessible(true); + String key = (String) field.get(owner); + var registry = System.getProperties(); + String original = registry.getProperty(key); + String token = original.substring(0, original.indexOf(':')); + String adjacent = "synthetic.fence.adjacent." + token; + registry.setProperty(adjacent, "strings/data\nUnicode ๐ŸŽ‰\r\nowner:0:false"); + try { + for (Object malformed : List.of(token + ":-1:false", token + ":1025:false", token + ":00:false", + token + ":+0:false", token + ":ู :false", + token + ":0:False", token + ":0:false:extra", token + ":0:false\n", + "\uFEFF" + original, "not-a-uuid:0:false", new java.util.Properties())) { + registry.put(key, malformed); + assertThrows(IllegalStateException.class, owner::acquire); + assertThrows(IllegalStateException.class, () -> ServerDataPublicationFence.beginGeneration(file)); + assertSame(malformed, registry.get(key)); + assertEquals("strings/data\nUnicode ๐ŸŽ‰\r\nowner:0:false", registry.getProperty(adjacent)); + } + } finally { registry.setProperty(key, original); registry.remove(adjacent); owner.retire(); } + } + + @Test void failedReleaseCanRetryAfterOwnershipStateIsRepaired() throws Exception { + var owner = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + var lease = owner.acquire(); + owner.retire(); + var field = ServerDataPublicationFence.class.getDeclaredField("key"); + field.setAccessible(true); + String key = (String) field.get(owner); + var registry = System.getProperties(); + String original = registry.getProperty(key); + registry.put(key, "synthetic malformed record"); + assertThrows(IllegalStateException.class, lease::close); + registry.setProperty(key, original); + lease.close(); + var successor = ServerDataPublicationFence.beginGeneration(directory.resolve("ServerData.yml")); + successor.retire(); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java index 1991dfedd1..8fe17df867 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/util/VoteTaskAdmissionTest.java @@ -16,6 +16,13 @@ import com.bencodez.votingplugin.util.VoteTaskAdmission; class VoteTaskAdmissionTest { + @Test + void unavailableExecutorIsRejectionWithoutRunningTheTask() { + var ran = new java.util.concurrent.atomic.AtomicBoolean(); + assertFalse(VoteTaskAdmission.trySubmit(null, () -> ran.set(true))); + assertFalse(VoteTaskAdmission.trySchedule(null, () -> ran.set(true), 5, TimeUnit.SECONDS)); + assertFalse(ran.get()); + } @Test void rejectedSubmissionIsReportedWithoutPropagating() { ScheduledExecutorService executor = mock(ScheduledExecutorService.class); diff --git a/docs/startup-failure-shutdown.md b/docs/startup-failure-shutdown.md new file mode 100644 index 0000000000..d886e54899 --- /dev/null +++ b/docs/startup-failure-shutdown.md @@ -0,0 +1,105 @@ +# Startup failure and shutdown + +VotingPlugin may be unloaded after a partial startup. Shutdown therefore treats each optional subsystem independently: +a failed close does not prevent the remaining queues, reminders, cooldown checks, signs, handlers, or executors from being +cleaned up. Each unload step is marked complete only after it returns successfully. A repeated unload retries steps +that propagated a failure and skips successful steps, while timed persistence continues through its existing coalesced +retry owner. A timer whose shutdown fails retains its reference for the next cleanup attempt. The vote timer uses a bounded termination wait, forces cancellation if it does not terminate, and restores +the interrupt flag when shutdown is interrupted. Concurrent unload callbacks do not start a second cleanup pass; repeated callbacks remain safe. + +## Building the complete SQL failure fix + +The MySQL authentication-failure regression exercises the corrected SimpleAPI and AdvancedCore constructors. +SimpleAPI PR #111 and AdvancedCore PR #361 supply the merged constructor corrections. The generation-fenced final +publication integration additionally requires the AdvancedCore publication and shutdown APIs from PR #360. +An AdvancedCore snapshot without those APIs cannot compile this candidate. The POM retains the normal +AdvancedCore and SimpleAPI snapshot coordinates; it does not pin the earlier SimpleAPI snapshot 40. + +Validate the exact candidate AdvancedCore artifact and its SimpleAPI scheduler inputs, including existing consumer +binary compatibility. Repeat consumer validation against compatible normally published dependencies before marking +the integration ready. A passing build with a locally reconstructed candidate does not establish normal dependency +delivery or native server acceptance. CI no longer builds an older AdvancedCore source checkout before testing VotingPlugin. + +Run the focused lifecycle and timed-vote regressions, then the required clean package: + +```shell +mvn -B -f VotingPlugin/pom.xml -Dtest=VotingPluginMainShutdownTest,TimeQueueShutdownPersistenceTest,TimeQueueRecoveryCheckpointTest,TimeQueueHandlerRejectionTest,VoteTaskAdmissionTest,VotiferEventDisabledVoteSiteTest,VotifierVoteOverflowQueueTest,CorePlatformIsolationTest test +mvn -B -f VotingPlugin/pom.xml clean package +``` + +The distributable VotingPlugin JAR includes the required libraries. Server owners do not need a separate SimpleAPI or +AdvancedCore installation, storage fallback, configuration migration or schema migration. Invalid MySQL credentials +still abort startup. The automated SQL test injects an in-process JDBC authentication rejection; it does not establish +live external MySQL or native Paper/Folia startup behavior. + +Timed-vote shutdown publication shares an admission fence with queue additions and cache-clearing callbacks. A task that +outlives the executor's bounded termination wait persists its late timed vote through that same owner. Shutdown saves retire successfully saved prefixes; an unsuccessful write retains its vote and cache index for a later +unload retry, without repeating unrelated cleanup. Timed-cache writes report I/O failures rather than relying on the legacy file helper's +log-only error handling. Ordinary `TimeQueueHandler.save()` checkpoints pending votes while keeping their live queue entries; `saveForShutdown()` seals processing. +The existing ServerData file format and persistence model are retained: this is not a new transactional reward system or +an exactly-once guarantee across arbitrary vote event handlers, chat, rewards and disk writes. + +A timed vote remains owned while its event is being prepared. If shutdown wins before event dispatch, it is saved for ordinary timed-cache recovery. Once event dispatch starts, external handlers may already have acted: shutdown or a thrown dispatch error retains that occurrence under `VotingPlugin.UncertainTimedVotes.` in `ServerData.yml` and logs a warning. These records contain the original name, service and timestamp and are **not automatically replayed**. An administrator must compare them with voting data/logs before deciding whether any recovery is needed; a completed event can still leave a conservative review record when shutdown raced it. Repeated persistence attempts use the same ID. Shutdown does not wait indefinitely for external event handlers. + +Before each event handoff, the queue saves its undispatched suffix and a conservative in-flight review record together. +A confirmed normal event return clears only that occurrence's review record. Cancellation also checkpoints any later +admissions, then continues draining other queued votes. A later checkpoint failure retains the undispatched suffix +both live and recoverable. Direct and scheduled processing use the same recovery boundary; neither +clears the whole recovery cache before the first event. Failed preparation does not invoke the event, and failed +completion publication does not automatically repeat an event that already returned. These writes use the existing +ServerData YAML persistence model, not a crash-atomic transaction or an exactly-once external-effects guarantee. + +A successful quarantine releases the finished dispatch's live guard so unaffected votes can resume. A failed quarantine +keeps its original identity until a later processing or unload attempt can retain it; that attempt does not invoke the +ambiguous event again. In-flight records are transient during normal processing, and a crash before actual dispatch can +leave a conservative record too. Stop processing before manually reconciling retained records; their presence alone does +not prove a reward ran or failed. + +The stopped vote timer remains visible until overflow and event-handler cleanup finishes. A missing timer is treated as +rejected admission, allowing a late Votifier callback to use the existing overflow path rather than fail on a null +reference. No accepted task is run inline on the event thread as a fallback. + +Timed-vote shutdown persistence has one retained daemon worker per handler. Each shutdown call waits at most one second +for that worker, including when a preparation/completion write holds the queue monitor or the shutdown write itself +stalls. Repeated calls join the same pending work; a completed failure remains retryable with the original queue/index. +An interrupted wait preserves the caller's interrupt flag and does not cancel persistence. A timeout reports pending +ownership, **not confirmed recovery**: if the process exits before storage resumes, new pending writes may not finish. +Stopping is published before waiting, so a stalled preparation cannot authorize a new event when it resumes after stop. +This bound covers the timed-queue persistence handoff; it does not promise a deadline for every other subsystem's I/O. + +Participating VotingPlugin generations reserve the server-data path in the JVM before AdvancedCore initializes or reads +its file. A replacement fails before startup if the previous generation still owns a write or shutdown handoff. It does +not wait for that filesystem operation on the lifecycle thread. The pending worker keeps its reservation through the +entire write and until stopped vote producers terminate, including an empty initial snapshot followed by late admission +and an initial publication failure. Disable also retains one coalesced retirement reservation independently of the timed +queue, covering AdvancedCore's heartbeat, login, inventory and time-checker executors until they actually terminate. +Failed startup remains reserved until its disable cleanup runs; a missing timed queue does not imply those writers stopped. +SDK server-data saves also retain the generation directly at both normal and durable time-transition publication hooks, +so platform-scheduled callbacks outside those executors, including the SDK version update, cannot overwrite a successor. +The adapter delegates to the existing SDK configuration/file rather than keeping a copied YAML state. VotingPlugin's +server-data wrapper remains bound to its original file and generation even if the plugin object is enabled again. +Lazy first access validates ownership before exposing mutable configuration. A retained SDK file keeps its predecessor +fence during re-enable until AdvancedCore actually creates the replacement file. +After that work ends, normal startup can proceed. Stale generations cannot publish through these server-data save methods +after a successor owns the path; failed shutdown writes remain retryable until such replacement. +This coordinates participating generations at the same normalized path within a JVM, including separate class loaders; +it is not a multi-process or filesystem-alias coordination contract. Existing YAML, dependencies and configuration remain +unchanged. A full process restart clears the old JVM's workers and in-memory reservations. + +Normal preparation, completion and quarantine/checkpoint failures retain their existing vote/identity and request one +coalesced retry with bounded backoff. Retry admission does not reset failure backoff; successful processing does. The +processing guard is released before requesting another attempt. Shutdown fences those callbacks, and ambiguous completed +events are quarantined without being dispatched again. The installed SimpleAPI asynchronous delay is in seconds; retries +start at one second and cap at sixty seconds. Rejected retry scheduling reports retained ownership, not durable +recovery that has not been confirmed. + +VoteParty current, required and remaining placeholders read an immutable live display +snapshot. Startup initializes it before placeholder registration. Ordinary mutations +refresh it through the backing SDK owner before disk publication, including when a +callback changes live counters and then fails. Setup, load, reload and case-mode +changes refresh it from the same authoritative file. Native final publication keeps +staged counters hidden until the real owner completes; cancelling a caller's +observation does not cancel that owner or release its generation. A stalled writer therefore cannot hold a placeholder request on +the file publication monitor. This projection represents live counters, including +mutations whose save fails; it is not a durable-write acknowledgement. Reward and +vote accounting continue to use authoritative state rather than this projection.