diff --git a/AGENTS.md b/AGENTS.md index 66c829d305..c5fe636e6e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,8 +52,8 @@ For compatibility-sensitive changes, keep regression coverage for established be 2. Do not access Bukkit entities, inventories, worlds, or other thread-confined state from arbitrary async callbacks. 3. Reward execution must not be lost or duplicated across delay, disconnect, reload, retry, or partial failure. Preserve the component's documented delivery contract. 4. Read-only lookups must not create users, mutate caches, or trigger persistence unless that behavior is part of the established API. -5. Keep user identity and UUID resolution consistent across online/offline paths. Coordinate cache updates, database writes, resets, and shutdown flushing. -6. Reload and disable must cancel or retire tasks, listeners, executors, connections, and callbacks without allowing stale work to mutate new state. +5. Keep user identity and UUID resolution consistent across online/offline paths. Coordinate cache updates, database writes, resets, and shutdown flushing. Final shared-runtime retirement fences queued notifications before marking storage retiring; callbacks already holding lifecycle admission may finish and re-enter storage. The optional pre-storage shutdown stage must finish before native storage retirement. Platform cleanup and the completion hook may run earlier on deferred shutdown; consumers must retain their own persistence owner until their stage settles. +6. Reload and disable must cancel or retire tasks, listeners, executors, connections, and callbacks without allowing stale work to mutate new state. Native ServerData admission precedes early enable hooks and reads and is shared across shaded classloaders through bounded opaque JVM Properties tokens. Release only after successful publication/preparation and actual captured native executor termination; failed or cancelled stages retain admission until JVM restart because settlement alone cannot prove drain. Retain the originating Properties object for release. Repeated disable must not submit stale final writes after release. Final whole-file publications settle in order even for asynchronous owners. A visible native final replacement gets one bounded settlement retry; if unconfirmed, fence that owner against subsequent writes/reload/setup so another save cannot commit its unacknowledged values. 7. GUI/editor actions must recheck permissions and ownership and must not duplicate items, lose configuration, or save to the wrong path or user. 8. Optional integrations must be guarded against absent classes and incompatible versions. One platform must not load another platform's API accidentally. diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index e9870a6a8e..3616f10ec7 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -238,7 +238,7 @@ com.bencodez simpleapi - + 1.0.2-SNAPSHOT compile diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index dc3d627f67..0119155b7d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -91,6 +91,54 @@ public abstract class AdvancedCorePlugin extends JavaPlugin { private static AdvancedCorePlugin javaPlugin; + + /** Lifecycle admission is independent from consumer replay/checkpoint synchronization. */ + private final Object lifecycleLock = new Object(); + private com.bencodez.advancedcore.lifecycle.ServerDataOwnership serverDataOwnership; + private boolean advancedCoreShutdownStarted; + private boolean advancedCoreEnableStarted; + private boolean serverDataStartupRejected; + + /** Native file admission also covers consumer hooks that read ServerData early. */ + private void acquireServerDataOwnership() { + synchronized (lifecycleLock) { + if (serverDataOwnership != null && !serverDataOwnership.isReleased()) { + if (!advancedCoreShutdownStarted) return; + throw new IllegalStateException("Previous ServerData shutdown has not finished"); + } + try { + serverDataOwnership = com.bencodez.advancedcore.lifecycle.ServerDataOwnership.acquire( + new File(getDataFolder(), "ServerData.yml").toPath()); + advancedCoreShutdownStarted = false; + advancedCoreEnableStarted = false; + serverDataStartupRejected = false; + } catch (RuntimeException | Error failure) { + serverDataStartupRejected = true; + throw failure; + } + } + } + + /** Internal lifecycle fence, independent of consumer shutdown-hook overrides. */ + public final boolean hasServerDataOwnership() { + synchronized (lifecycleLock) { + return serverDataOwnership != null && !serverDataOwnership.isReleased(); + } + } + + /** Internal lifecycle fence, independent of consumer shutdown-hook overrides. */ + public final void releaseServerDataOwnershipAfter(CompletionStage completion) { + com.bencodez.advancedcore.lifecycle.ServerDataOwnership ownership; + synchronized (lifecycleLock) { ownership = serverDataOwnership; } + if (ownership != null) ownership.releaseAfter(completion); + } + + private void cleanUpFailedStartup(Throwable failure) { + try { onDisable(); } + catch (Throwable cleanupFailure) { + if (cleanupFailure != failure) failure.addSuppressed(cleanupFailure); + } + } /** * Gets the singleton instance of this plugin. @@ -843,7 +891,16 @@ private void loadHandle() { /** * Load AdvancedCore hook */ - public void loadHook() { + public void loadHook() { + acquireServerDataOwnership(); + try { loadCoreHook(); } + catch (RuntimeException | Error failure) { + cleanUpFailedStartup(failure); + throw failure; + } + } + + private void loadCoreHook() { serverDataFile = new ServerData(this); hologramHandler = new HologramHandler(this); @@ -1381,22 +1438,81 @@ private void loadVersionFile() { } @Override - public void onDisable() { - var presence = playerPresenceService; - playerPresenceService = null; - if (presence != null) presence.close(); - var lookup = playerPresenceLookup; - playerPresenceLookup = null; - if (lookup != null) { - lookup.close(); - org.bukkit.event.HandlerList.unregisterAll(lookup); + public void onDisable() { + long startedNanos = System.nanoTime(); + synchronized (lifecycleLock) { + if (advancedCoreShutdownStarted || serverDataStartupRejected) return; + advancedCoreShutdownStarted = true; + } + try { + if (serverDataOwnership != null) serverDataOwnership.beginShutdown(startedNanos); + var presence = playerPresenceService; + playerPresenceService = null; + try { + if (presence != null) presence.close(); + } finally { + var lookup = playerPresenceLookup; + playerPresenceLookup = null; + if (lookup != null) { + try { lookup.close(); } + finally { org.bukkit.event.HandlerList.unregisterAll(lookup); } + } + } + } finally { + try { new com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle(this).shutdown(startedNanos); } + finally { if (javaPlugin == this) javaPlugin = null; } } - new com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle(this).shutdown(); - javaPlugin = null; } + /** + * Gives a plugin one asynchronous admission boundary before native user + * storage starts retiring. The default keeps the established shutdown + * sequence unchanged. Implementations must return promptly and may use the + * returned stage to account for work admitted before storage retirement; + * they must not wait synchronously for that work here. + * Exceptional completion or cancellation is reported after shared storage + * retirement settles; it does not skip flushing accepted cache writes. + * + * @return completion of plugin-owned pre-storage-shutdown work + */ + public CompletionStage onBeforeStorageShutdown() { + return CompletableFuture.completedFuture(null); + } + + /** + * Called after platform cleanup and submission of the final permission-data save. + * Deferred storage retirement and final file saves may still be running; implementations must + * retain any owner needed by their pre-storage stage until that stage settles. + * The default is a no-op so existing subclasses retain their lifecycle. + */ + public void onShutdownComplete() { } + + /** + * Called on the established lifecycle thread after final file work is admitted. + * The stage settles only when all final file owners confirm publication (or fail). + * Consumers must retain required owners until both their preparation and this + * stage settle, and must not synchronously wait or include owner termination in it. + * Existing zero-argument hooks keep their thread and invocation timing. + */ + public void onShutdownComplete(CompletionStage finalFilesStage) { + onShutdownComplete(); + } + @Override - public void onEnable() { + public void onEnable() { + synchronized (lifecycleLock) { + acquireServerDataOwnership(); + if (advancedCoreEnableStarted) throw new IllegalStateException("AdvancedCore is already enabled"); + advancedCoreEnableStarted = true; + } + try { enableCore(); } + catch (RuntimeException | Error failure) { + cleanUpFailedStartup(failure); + throw failure; + } + } + + private void enableCore() { javaPlugin = this; com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle.RuntimeExecutors runtimeExecutors = com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle .createRuntimeExecutors(this); @@ -1716,7 +1832,7 @@ private void reloadAdvancedCoreNow(boolean userStorage) { } private void finishReloadAdvancedCore() { - timeChecker.update(); + timeChecker.requestUpdate(); TabCompleteHandler.getInstance().reload(); TabCompleteHandler.getInstance().loadTabCompleteOptions(); getRewardHandler().checkSubRewards(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java index 85a39d263d..33ff20ea49 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/item/FullInventoryHandler.java @@ -9,6 +9,7 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; +import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; @@ -55,6 +56,8 @@ public class FullInventoryHandler { private final AdvancedCorePlugin plugin; private final ReentrantReadWriteLock deliveryLock = new ReentrantReadWriteLock(true); private final AtomicBoolean shuttingDown = new AtomicBoolean(); + private final java.util.concurrent.atomic.AtomicReference> shutdownPersistence = + new java.util.concurrent.atomic.AtomicReference<>(); @Getter private ScheduledExecutorService timer; @@ -189,6 +192,8 @@ private void scheduleItemDelivery(Player player, ItemStack[] item, CompletableFu if (pendingOverflow) { persistReservedOverflowAsync(reservationId).whenComplete((ignored, failure) -> { if (failure == null) completeReplayOverflow(reservationId, completion); + else if (failure instanceof UnconfirmedOverflowPublication) + scheduleReservedOverflowPersistenceRetry(reservationId, completion); else completeStartedOverflowWithFallback(player, deliveryPlayerId, reservationId, completion); }); } else { @@ -264,7 +269,8 @@ private static void rethrowDeliveryFailure(Throwable failure) { public synchronized void loadTimer() { if (timer == null || timer.isShutdown() || timer.isTerminated()) { - timer = Executors.newSingleThreadScheduledExecutor(); + timer = Executors.newSingleThreadScheduledExecutor( + com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime.daemonThreads("AdvancedCore-FullInventory")); checkTask = null; } if (checkTask != null && !checkTask.isDone() && !checkTask.isCancelled()) { @@ -276,18 +282,138 @@ public synchronized void loadTimer() { } public synchronized void shutdown() { + // Reuse the same final publication for legacy and asynchronous shutdown. + // A second shutdown must not retire a worker needed by an earlier save. + shutdownAsync(Runnable::run).whenComplete((ignored, failure) -> { + if (failure != null) plugin.getLogger().log(Level.WARNING, + "Failed to confirm pending full-inventory items", failure); + }); + } + + /** + * Fences player delivery immediately and exposes the actual final disk save. + * The caller owns the persistence executor; pending or failed saves never + * acknowledge replay overflow and must keep storage retirement ordered behind it. + */ + public CompletionStage shutdownAsync(Executor persistenceExecutor) { + CompletableFuture completion = new CompletableFuture<>(); + // Keep one shared observation stage; each CompletableFuture view is isolated. + CompletionStage observation = completion.minimalCompletionStage(); + if (!shutdownPersistence.compareAndSet(null, observation)) return shutdownPersistence.get(); shuttingDown.set(true); - if (checkTask != null) { - checkTask.cancel(false); - checkTask = null; + ScheduledFuture task = checkTask; + if (task != null) task.cancel(false); + // Reject new timer work immediately, but drain already accepted continuations. + if (timer != null) timer.shutdown(); + ServerData serverData = plugin.getServerDataFile(); + if (!plugin.isLoadServerData() && (serverData == null || serverData.getData() == null)) { + // No native file was loaded. Still fence delivery and retire its timer, + // but do not snapshot or acknowledge any unpublished replay overflow. + if (timer != null) timer.shutdownNow(); + completion.complete(null); + return observation; } - // Flush accepted replay reservations while their completion stages can still - // advance the outer reward checkpoint. Stopping the executor first can discard - // an accepted persistence task and later replay the already-inserted items. - saveDurably(); - if (timer != null) { - timer.shutdownNow(); + try { + persistenceExecutor.execute(() -> { + try { + finalSnapshot(serverData, false, persistenceExecutor).whenComplete((ignored, failure) -> { + if (timer != null) timer.shutdownNow(); + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } catch (Throwable failure) { + if (timer != null) timer.shutdownNow(); + completion.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + if (timer != null) timer.shutdownNow(); + } + return observation; + } + + private final java.util.Map overflowPublications = new HashMap<>(); + + private final Object overflowPublicationOrder = new Object(); + private CompletableFuture overflowPublicationTail = CompletableFuture.completedFuture(null); + + private CompletionStage finalSnapshot(ServerData serverData, boolean requireRunning) { + return finalSnapshot(serverData, requireRunning, timer); + } + + private CompletionStage finalSnapshot(ServerData serverData, boolean requireRunning, Executor executor) { + CompletableFuture settled = new CompletableFuture<>(); + CompletableFuture previous; + synchronized (overflowPublicationOrder) { + previous = overflowPublicationTail; + overflowPublicationTail = settled; + } + Runnable publish = () -> { + try { + publishSnapshot(serverData, requireRunning).whenComplete((ignored, failure) -> { + if (failure == null) settled.complete(null); + else settled.completeExceptionally(failure); + }); + } catch (Throwable failure) { settled.completeExceptionally(failure); } + }; + // Claim order before invoking any owner or callback. Snapshot capture belongs + // to that order too: an older asynchronous replacement must not publish last. + if (previous.isDone()) publish.run(); + else previous.whenComplete((ignored, failure) -> { + // Owner completion may run on a platform thread; never start disk work there. + try { executor.execute(publish); } + catch (Throwable rejected) { settled.completeExceptionally(rejected); } + }); + // A caller cancelling its view must not release the actual publication queue. + return settled.copy(); + } + + private CompletionStage publishSnapshot(ServerData serverData, boolean requireRunning) { + HashMap reservations; + CompletionStage publication; + deliveryLock.writeLock().lock(); + try { + if (requireRunning && shuttingDown.get()) return CompletableFuture.failedFuture( + new IllegalStateException("Final inventory publication owns pending overflow")); + reservations = new HashMap<>(replayOverflowReservations); + YamlConfiguration snapshot = pendingItemsSnapshot(reservations.values()); + YamlConfiguration replacement = copySection(snapshot, "FullInventory"); + reservations.keySet().forEach(id -> overflowPublications.merge(id, 1, Integer::sum)); + try { publication = serverData.mutateFinalData(data -> replaceSection(data, "FullInventory", replacement)); } + catch (Throwable failure) { publication = CompletableFuture.failedFuture(failure); } + if (publication == null) publication = CompletableFuture.failedFuture( + new IllegalStateException("ServerData owner returned no publication stage")); + } finally { deliveryLock.writeLock().unlock(); } + // Publication may belong to a different worker. Never join while holding + // delivery/YAML admission. Settle promotion and fallback ownership together. + return publication.whenComplete((ignored, failure) -> { + deliveryLock.writeLock().lock(); + try { + reservations.keySet().forEach(id -> overflowPublications.computeIfPresent(id, + (key, count) -> count == 1 ? null : count - 1)); + if (failure == null) reservations.forEach(this::promoteReservedOverflowLocked); + } finally { deliveryLock.writeLock().unlock(); } + if (failure == null) reservations.keySet().forEach(id -> completeReplayOverflow(id, null)); + }); + } + + /** Caller holds delivery admission; only pending stored items are serialized. */ + private YamlConfiguration pendingItemsSnapshot(Collection reservedOverflows) { + HashMap> snapshotItems = new HashMap<>(); + items.forEach((uuid, values) -> snapshotItems.put(uuid, new ArrayList<>(values))); + for (ReservedOverflow overflow : reservedOverflows) { + if (!overflow.items.isEmpty()) snapshotItems.computeIfAbsent(overflow.playerId, + ignored -> new ArrayList<>()).addAll(overflow.items); } + YamlConfiguration snapshot = new YamlConfiguration(); + long now = System.currentTimeMillis(); + for (Entry> entry : snapshotItems.entrySet()) { + String path = "FullInventory." + entry.getKey(); + for (int i = 0; i < entry.getValue().size(); i++) snapshot.set(path + ".Items." + i, entry.getValue().get(i)); + snapshot.set(path + ".Time", now); + } + return snapshot; } /** @@ -303,51 +429,62 @@ public void save() { /** Saves pending items and reports whether the disk snapshot completed. */ public boolean saveDurably() { - HashMap reservations; - boolean saved; - deliveryLock.writeLock().lock(); + return saveDurably(plugin.getServerDataFile()); + } + + private boolean saveDurably(ServerData serverData) { try { - reservations = new HashMap<>(replayOverflowReservations); - if (!savePendingItemsLocked(reservations.values())) return false; - for (Entry entry : reservations.entrySet()) { - promoteReservedOverflowLocked(entry.getKey(), entry.getValue()); - } - saved = true; - } finally { - deliveryLock.writeLock().unlock(); - } - if (saved) { - for (String reservationId : reservations.keySet()) completeReplayOverflow(reservationId, null); + // Native owners complete synchronously. A facade's queued save is not + // durable yet; do not block the caller or claim that it has completed. + var publication = finalSnapshot(serverData, false).toCompletableFuture(); + if (!publication.isDone()) return false; + publication.join(); + return true; + } catch (Throwable failure) { + plugin.getLogger().log(Level.WARNING, "Failed to confirm pending full-inventory items", failure); + return false; } - return true; } - /** Persists one replay reservation before making it visible to ordinary pending checks. */ + /** A failed owner stage may already have replaced disk bytes; never drop/replay it. */ + private static final class UnconfirmedOverflowPublication extends RuntimeException { + private static final long serialVersionUID = 1L; + private UnconfirmedOverflowPublication(Throwable cause) { super("Overflow publication is unconfirmed", cause); } + } + + /** Persists replay overflow through the actual owner's acknowledged publication. */ private CompletionStage persistReservedOverflowAsync(String reservationId) { CompletableFuture persisted = new CompletableFuture<>(); try { timer.execute(() -> { - if (persistReservedOverflow(reservationId)) persisted.complete(null); - else persisted.completeExceptionally( - new IllegalStateException("Unable to persist full-inventory reward overflow")); + try { + persistReservedOverflow(reservationId).whenComplete((ignored, failure) -> { + if (failure == null) persisted.complete(null); + else persisted.completeExceptionally(new UnconfirmedOverflowPublication(failure)); + }); + } catch (Throwable failure) { persisted.completeExceptionally(failure); } }); - } catch (Throwable failure) { - persisted.completeExceptionally(failure); - } + } catch (Throwable failure) { persisted.completeExceptionally(failure); } return persisted; } - private boolean persistReservedOverflow(String reservationId) { + private CompletionStage persistReservedOverflow(String reservationId) { deliveryLock.writeLock().lock(); try { ReservedOverflow reservation = replayOverflowReservations.get(reservationId); - if (reservation == null || reservation.items.isEmpty()) return true; - if (!savePendingItemsLocked(java.util.List.of(reservation))) return false; - promoteReservedOverflowLocked(reservationId, reservation); - return true; - } finally { - deliveryLock.writeLock().unlock(); - } + if (reservation == null || reservation.items.isEmpty()) + return CompletableFuture.completedFuture(null); + if (shuttingDown.get()) return CompletableFuture.failedFuture( + new IllegalStateException("Final inventory publication owns pending overflow")); + } finally { deliveryLock.writeLock().unlock(); } + // A disabled/uninitialized native file has not admitted any publication. + // Keep its established safe overflow fallback instead of treating it as uncertain. + ServerData owner = plugin.getServerDataFile(); + if (owner == null || owner.getData() == null) + throw new IllegalStateException("Full-inventory persistence owner is uninitialized"); + // Include every unpublished reservation. Invoke completion callbacks only + // after releasing this caller's admission, including a synchronous owner. + return finalSnapshot(owner, true); } /** @@ -380,6 +517,9 @@ private boolean dropReservedOverflowOwnedPlayer(Player player, UUID playerId, St try { ReservedOverflow reservation = replayOverflowReservations.get(reservationId); if (reservation == null) return true; + // A pending final publication already owns these items. An older queued + // fallback cannot drop them while the owner's snapshot is publishing. + if (shuttingDown.get() || overflowPublications.containsKey(reservationId)) return false; if (!playerId.equals(reservation.playerId)) return false; ArrayList remaining = new ArrayList<>(); for (ItemStack item : reservation.items) { @@ -404,8 +544,12 @@ private void scheduleReservedOverflowPersistenceRetry(String reservationId, Comp } try { timer.schedule(() -> { - if (persistReservedOverflow(reservationId)) completeReplayOverflow(reservationId, completion); - else scheduleReservedOverflowPersistenceRetry(reservationId, completion); + try { + persistReservedOverflow(reservationId).whenComplete((ignored, failure) -> { + if (failure == null) completeReplayOverflow(reservationId, completion); + else scheduleReservedOverflowPersistenceRetry(reservationId, completion); + }); + } catch (Throwable failure) { scheduleReservedOverflowPersistenceRetry(reservationId, completion); } }, 30, TimeUnit.SECONDS); } catch (Throwable ignored) { // Shutdown may reject this retry. Leave the completion pending: acknowledging @@ -419,56 +563,6 @@ private void completeReplayOverflow(String reservationId, CompletableFuture reservedOverflows) { - FileConfiguration data = null; - YamlConfiguration previous = null; - try { - ServerData serverData = plugin.getServerDataFile(); - if (serverData == null || serverData.getData() == null) return false; - - HashMap> snapshotItems = new HashMap<>(); - for (Entry> entry : items.entrySet()) { - snapshotItems.put(entry.getKey(), new ArrayList<>(entry.getValue())); - } - for (ReservedOverflow reservedOverflow : reservedOverflows) { - if (!reservedOverflow.items.isEmpty()) { - snapshotItems.computeIfAbsent(reservedOverflow.playerId, ignored -> new ArrayList<>()) - .addAll(reservedOverflow.items); - } - } - - YamlConfiguration snapshot = new YamlConfiguration(); - long now = System.currentTimeMillis(); - for (Entry> entry : snapshotItems.entrySet()) { - String basePath = "FullInventory." + entry.getKey(); - for (int i = 0; i < entry.getValue().size(); i++) { - snapshot.set(basePath + ".Items." + i, entry.getValue().get(i)); - } - snapshot.set(basePath + ".Time", now); - } - - data = serverData.getData(); - previous = copySection(data, "FullInventory"); - data.set("FullInventory", null); - ConfigurationSection replacement = snapshot.getConfigurationSection("FullInventory"); - if (replacement != null) { - for (String path : replacement.getKeys(true)) { - if (!replacement.isConfigurationSection(path)) data.set("FullInventory." + path, replacement.get(path)); - } - } - serverData.saveData(); - return true; - } catch (Exception e) { - if (data != null && previous != null) replaceSection(data, "FullInventory", previous); - try { - plugin.getLogger().log(Level.WARNING, "Failed to save pending full-inventory items", e); - } catch (Throwable ignored) { - // A partially initialized plugin can still retain the reservation for retry. - } - return false; - } - } private static YamlConfiguration copySection(ConfigurationSection source, String path) { YamlConfiguration copy = new YamlConfiguration(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java index e5502f55e4..31d5770697 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/permissions/PermissionHandler.java @@ -5,6 +5,9 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; +import java.util.concurrent.Executor; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; import java.util.regex.Pattern; @@ -14,6 +17,7 @@ import org.bukkit.permissions.PermissionAttachment; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.data.ServerData; import com.bencodez.simpleapi.time.ParsedDuration; import lombok.Getter; @@ -49,8 +53,11 @@ public class PermissionHandler { private final ConcurrentHashMap permsToAdd = new ConcurrentHashMap<>(); @Getter - private final ScheduledExecutorService timer = Executors.newScheduledThreadPool(1); + private final ScheduledExecutorService timer = Executors.newScheduledThreadPool(1, + com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime.daemonThreads("AdvancedCore-Permissions")); private final AtomicBoolean acceptingExpirations = new AtomicBoolean(true); + private final java.util.concurrent.atomic.AtomicReference> shutdownPersistence = + new java.util.concurrent.atomic.AtomicReference<>(); private final Object[] stateLocks = new Object[64]; public PermissionHandler(AdvancedCorePlugin plugin) { @@ -387,6 +394,53 @@ public void shutDown() { plugin.getServerDataFile().saveData(); } + /** Fence callbacks now; the guarded shutdown worker snapshots only stored permission state. */ + public CompletionStage shutDownAsync(Executor persistenceExecutor) { + CompletableFuture completion = new CompletableFuture<>(); + if (!shutdownPersistence.compareAndSet(null, completion)) return shutdownPersistence.get(); + acceptingExpirations.set(false); + timer.shutdownNow(); + ServerData serverData = plugin.getServerDataFile(); + if (!plugin.isLoadServerData() && (serverData == null || serverData.getData() == null)) { + // Expiration admission and its timer are already stopped; intentionally + // unloaded ServerData has no final permission file to publish. + completion.complete(null); + return completion; + } + try { + persistenceExecutor.execute(() -> { + try { + // Snapshot handler monitors before the shared YAML monitor: expiration + // callbacks are fenced, and no Bukkit attachment is touched here. + java.util.Map> snapshot = new java.util.HashMap<>(); + collectTimedPerms(perms, snapshot); + collectTimedPerms(permsToAdd, snapshot); + serverData.mutateFinalData(data -> snapshot.forEach(data::set)) + .whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + return completion; + } + + private void collectTimedPerms(ConcurrentHashMap map, + java.util.Map> snapshot) { + for (PlayerPermissionHandler handle : map.values()) { + java.util.Map timed = handle.timedPermissionSnapshot(); + if (timed.isEmpty()) continue; + ArrayList values = new ArrayList<>(); + timed.forEach((permission, expires) -> values.add(permission + "%line%" + expires)); + snapshot.put("TimedPermissions." + handle.getUuid(), values); + } + } + private void saveTimedPerms(ConcurrentHashMap map) { for (PlayerPermissionHandler handle : map.values()) { java.util.Map snapshot = handle.timedPermissionSnapshot(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java index cf9ad558cf..ab85006e39 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardHandler.java @@ -8,8 +8,9 @@ import java.util.Set; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CompletionStage; -import java.util.concurrent.Executors; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.TimeUnit; import org.bukkit.configuration.Configuration; @@ -50,8 +51,47 @@ public class RewardHandler { AdvancedCorePlugin plugin; + private final Object replayAdmissionLock = new Object(); + private final CompletableFuture replayChecksDrained = new CompletableFuture<>(); + private boolean replayAdmissionClosed; + private int activeReplayChecks; + + /** + * Admit the synchronous claim/registration part of a persisted queue check. + * Closing its token means every async replay tail has been registered, not + * that its reward or durable acknowledgement has completed. + */ + public PersistedReplayCheck admitPersistedReplayCheck() { + synchronized (replayAdmissionLock) { + if (replayAdmissionClosed) return null; + activeReplayChecks++; + return new PersistedReplayCheck(); + } + } + + public final class PersistedReplayCheck implements AutoCloseable { + private boolean closed; + private PersistedReplayCheck() { } + @Override public void close() { + boolean drained; + synchronized (replayAdmissionLock) { + if (closed) return; + closed = true; + drained = --activeReplayChecks == 0 && replayAdmissionClosed; + } + // Completion callbacks may enter the manager. Never run them while + // holding the admission lock or doing blocking storage work. + if (drained) replayChecksDrained.complete(null); + } + } + + private final CompletableFuture delayedTimerTermination = new CompletableFuture<>(); + private final CompletionStage delayedTimerRetirement = delayedTimerTermination.minimalCompletionStage(); @Getter - private ScheduledExecutorService delayedTimer = Executors.newSingleThreadScheduledExecutor(); + private ScheduledExecutorService delayedTimer = new ScheduledThreadPoolExecutor(1, + com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime.daemonThreads("AdvancedCore-DelayedRewards")) { + @Override protected void terminated() { delayedTimerTermination.complete(null); } + }; public RewardHandler(AdvancedCorePlugin plugin) { this.plugin = plugin; @@ -291,14 +331,40 @@ public void setupExample() { } public void shutdown() { + shutdown(10, TimeUnit.SECONDS); + } + + /** + * Close delayed-task and persisted-replay admission without blocking. + * Completion means the actual worker and admitted synchronous replay checks + * have exited, including tasks that ignored interruption. + * Persisted replay callbacks can outlive that worker; the storage lifecycle + * separately drains their existing per-user replay tails before retiring. + */ + public CompletionStage beginShutdown() { + boolean drained; + synchronized (replayAdmissionLock) { + replayAdmissionClosed = true; + drained = activeReplayChecks == 0; + } rewardEligibility.close(); + if (drained) replayChecksDrained.complete(null); delayedTimer.shutdown(); + return CompletableFuture.allOf(delayedTimerRetirement.toCompletableFuture(), replayChecksDrained) + .minimalCompletionStage(); + } + + /** Lifecycle callers share their remaining disable budget; legacy shutdown keeps its ten-second grace. */ + public void shutdown(long timeout, TimeUnit unit) { + java.util.Objects.requireNonNull(unit, "unit"); + beginShutdown(); try { - delayedTimer.awaitTermination(10, TimeUnit.SECONDS); + if (timeout > 0) delayedTimer.awaitTermination(timeout, unit); } catch (InterruptedException e) { - e.printStackTrace(); + Thread.currentThread().interrupt(); + } finally { + delayedTimer.shutdownNow(); } - delayedTimer.shutdownNow(); } public void sortInjectedRequirements() { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java index 135acfe8a4..2844294bf4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java @@ -39,6 +39,9 @@ public class TimeChecker implements TimeChangeTransition.Owner { private volatile ActiveTransition activeTransition; private CompletableFuture noActiveTransition = CompletableFuture.completedFuture(null); private CompletableFuture noPendingManualTransitions = CompletableFuture.completedFuture(null); + private CompletableFuture noRequestedUpdate = CompletableFuture.completedFuture(null); + private boolean requestedUpdatePending; + private boolean requestedUpdateRunning; private boolean acceptingTransitions = true; private boolean abortingTransitions; @@ -121,7 +124,7 @@ public CompletionStage beginShutdown() { synchronized (transitionLock) { acceptingTransitions = false; transitionLock.notifyAll(); - return CompletableFuture.allOf(noActiveTransition, noPendingManualTransitions); + return CompletableFuture.allOf(noActiveTransition, noPendingManualTransitions, noRequestedUpdate); } } @@ -275,7 +278,8 @@ public synchronized void loadTimer() { return; } timerLoaded = true; - timer = Executors.newSingleThreadScheduledExecutor(); + timer = Executors.newSingleThreadScheduledExecutor( + com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime.daemonThreads("AdvancedCore-Time")); if (plugin.getServerDataFile().getLastUpdated() > 0 && System.currentTimeMillis() - plugin.getServerDataFile().getLastUpdated() > TimeUnit.DAYS.toMillis(4)) { plugin.getServerDataFile().setIgnoreTime(true); @@ -318,6 +322,61 @@ public void setProcessingEnabled(boolean value) { plugin.debug("Local time change processing " + (value ? "enabled" : "disabled")); } + /** Requests one coalesced calendar check on its existing background timer. + * Reload callers may be Bukkit/Folia owners; asynchronous date events must + * never be dispatched inline there. Shutdown drains an admitted check. + */ + public void requestUpdate() { + final ScheduledExecutorService executor; + final CompletableFuture drain; + synchronized (transitionLock) { + if (!acceptingTransitions || abortingTransitions || requestedUpdatePending || timer == null) return; + executor = timer; + requestedUpdatePending = true; + drain = new CompletableFuture<>(); + noRequestedUpdate = drain; + } + try { + executor.execute(() -> { + boolean started = false; + try { + synchronized (transitionLock) { + if (!acceptingTransitions || abortingTransitions || timer != executor) return; + requestedUpdateRunning = true; + started = true; + } + if (plugin != null && plugin.isEnabled()) runRecurringTask("requested time change check", this::update); + } finally { + synchronized (transitionLock) { + if (started) requestedUpdateRunning = false; + } + finishRequestedUpdate(drain); + } + }); + } catch (RejectedExecutionException rejected) { + finishRequestedUpdate(drain); + plugin.debug("Ignoring requested time check while the calendar timer is unavailable"); + } + } + + /** Settles an admitted request that has not started before deferred cleanup. */ + public void cancelQueuedUpdate() { + CompletableFuture drain = null; + synchronized (transitionLock) { + if (!acceptingTransitions && requestedUpdatePending && !requestedUpdateRunning) { + drain = noRequestedUpdate; + } + } + if (drain != null) finishRequestedUpdate(drain); + } + + private void finishRequestedUpdate(CompletableFuture drain) { + synchronized (transitionLock) { + if (noRequestedUpdate == drain) requestedUpdatePending = false; + } + drain.complete(null); + } + public void update() { if (plugin == null) return; if (hasTimeOffSet()) plugin.extraDebug("TimeHourOffSet: " + getTime().getHour() + ":" + getTime().getMinute()); @@ -326,7 +385,7 @@ public void update() { TimeChangeTransitionState pending = plugin.getServerDataFile().getPendingTimeChangeTransition(type); if (pending != null) { plugin.getLogger().info("Recovering pending " + type + " time change " + pending.id()); - startDetectedTransition(type, pending); + startDetectedTransition(type, pending, true); return; } } @@ -358,12 +417,12 @@ private void startDetectedTransition(TimeType type) { LocalDateTime current = getTime(); TimeChangeTransitionState persisted = plugin.getServerDataFile().beginTimeChangeTransition(type, periodKey(type, current), markerValue(type, current)); - startDetectedTransition(type, persisted); + startDetectedTransition(type, persisted, false); } - private void startDetectedTransition(TimeType type, TimeChangeTransitionState persisted) { + private void startDetectedTransition(TimeType type, TimeChangeTransitionState persisted, boolean recovered) { TimeChangeTransition transition = new TimeChangeTransition(this, persisted.id(), persisted.periodKey(), type); - startTransition(type, false, true, true, new DurableTransition(transition, persisted)); + startTransition(type, false, true, true, new DurableTransition(transition, persisted, recovered)); } private void startTransition(TimeType type, boolean fake, boolean preDate, boolean postDate, DurableTransition durable) { @@ -411,10 +470,6 @@ private void startTransition(TimeType type, boolean fake, boolean preDate, boole if (manualReservation) releaseManualTransition(); return; } - if (durable != null && !plugin.getServerDataFile().isPendingTimeChangeTransition(durable.persisted)) { - plugin.debug("Ignoring stale time change transition " + durable.persisted.id()); - return; - } CompletableFuture drain = new CompletableFuture<>(); active = new ActiveTransition(type, durable == null ? null : durable.transition, durable == null ? null : durable.persisted, drain, Thread.currentThread(), manualReservation); @@ -422,6 +477,34 @@ private void startTransition(TimeType type, boolean fake, boolean preDate, boole noActiveTransition = drain; activeProcessing = true; } + if (durable != null) { + // Reserve the drain before entering ServerData's monitor. A checked save + // can hold that monitor for arbitrary filesystem I/O, while shutdown must + // still acquire transitionLock to close admission and fence event dispatch. + boolean validated = false; + try { + var serverData = plugin.getServerDataFile(); + if (!serverData.isPendingTimeChangeTransition(durable.persisted)) { + plugin.debug("Ignoring stale time change transition " + durable.persisted.id()); + return; + } + if (durable.recovered) { + // The read is observational. Settle publication on the existing + // checked owner only after reserving the shutdown drain. Recheck + // under that owner's monitor so reload cannot revive a stale slot. + synchronized (serverData.publicationMonitor()) { + if (!serverData.isPendingTimeChangeTransition(durable.persisted)) return; + serverData.beginTimeChangeTransition(type, durable.persisted.periodKey(), + durable.persisted.markerValue()); + } + } + validated = true; + } finally { + // No event or lease has been published yet. Stale/failed validation + // releases this reservation without completing or rewriting its record. + if (!validated) retireTransition(active); + } + } dispatchTransition(active, type, fake, preDate, postDate); } @@ -649,10 +732,12 @@ private Lease(ActiveTransition active) { private static final class DurableTransition { private final TimeChangeTransition transition; private final TimeChangeTransitionState persisted; + private final boolean recovered; - private DurableTransition(TimeChangeTransition transition, TimeChangeTransitionState persisted) { + private DurableTransition(TimeChangeTransition transition, TimeChangeTransitionState persisted, boolean recovered) { this.transition = transition; this.persisted = persisted; + this.recovered = recovered; } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index 32fe185a57..ad41b062f8 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java @@ -546,6 +546,25 @@ private static final class ReplayClaims { private final CompletableFuture settled = new CompletableFuture<>(); } + /** + * Snapshot completion of already-admitted persisted replay, including its + * queue acknowledgement or restore callback. The lifecycle calls this from + * its manager-worker barrier after delayed-task and replay-check admission end, + * so a check deferred to that worker has appended its tail before the snapshot. + * This creates no new replay owner and does not admit or execute rewards. + */ + public static CompletionStage persistedReplayCompletion(AdvancedCorePlugin plugin) { + java.util.Objects.requireNonNull(plugin, "plugin"); + synchronized (plugin) { + synchronized (REPLAY_CLAIMS_LOCK) { + HashMap users = REPLAY_CLAIMS.get(plugin); + if (users == null || users.isEmpty()) return CompletableFuture.completedFuture(null); + return CompletableFuture.allOf(users.values().stream() + .map(claims -> claims.serialReplayTail).toArray(CompletableFuture[]::new)); + } + } + } + /** * User data fetch mode for this user. * @@ -1183,11 +1202,27 @@ public void checkDelayedTimedRewards() { } private void checkDelayedTimedRewards(ReplayPlayerState capturedState) { - UserDataManager manager = sharedReplayDataManager(); - if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { - ReplayPlayerState state = captureReplayPlayerState(); - if (manager.deferSharedStorageWork(() -> checkDelayedTimedRewards(state))) return; + RewardHandler.PersistedReplayCheck admission = plugin.getRewardHandler().admitPersistedReplayCheck(); + if (admission == null) return; + boolean deferred = false; + try { + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + // Transfer the original admission through the queued storage callback. + // Reacquiring after the shutdown fence would discard accepted work. + deferred = manager.deferSharedStorageWork(() -> { + try (admission) { checkAdmittedDelayedTimedRewards(state); } + }); + if (deferred) return; + } + checkAdmittedDelayedTimedRewards(capturedState); + } finally { + if (!deferred) admission.close(); } + } + + private void checkAdmittedDelayedTimedRewards(ReplayPlayerState capturedState) { plugin.debug("Checking timed/delayed for " + getPlayerName()); HashMap timed = getTimedRewards(); for (Entry entry : timed.entrySet()) { @@ -1269,32 +1304,44 @@ public void checkOfflineRewards() { * this pass; a concurrent replay may already own other pending occurrences. */ public CompletionStage checkOfflineRewardsAsync() { - return Reward.continueOnServerThread(plugin, this, - () -> CompletableFuture.completedFuture(captureReplayPlayerState())).thenCompose(state -> { - CompletableFuture> handoff = new CompletableFuture<>(); - Runnable work = () -> { - CompletableFuture outcome = new CompletableFuture<>(); - if (!handoff.complete(outcome)) return; + RewardHandler.PersistedReplayCheck admission = plugin.getRewardHandler().admitPersistedReplayCheck(); + if (admission == null) return CompletableFuture.completedFuture(null); + try { + CompletionStage captured = Reward.continueOnServerThread(plugin, this, + () -> CompletableFuture.completedFuture(captureReplayPlayerState())); + // Observe the owned handoffs, not the caller's cancellable dependent stage. + captured.whenComplete((state, failure) -> { if (failure != null) admission.close(); }); + CompletionStage owned = captured.thenCompose(state -> { + CompletableFuture> handoff = new CompletableFuture<>(); + handoff.whenComplete((stage, failure) -> { if (failure != null) admission.close(); }); + Runnable work = () -> { + CompletableFuture outcome = new CompletableFuture<>(); + if (!handoff.complete(outcome)) return; + try (admission) { + if (!plugin.isEnabled()) throw new IllegalStateException("Plugin disabled before offline replay"); + checkAdmittedOfflineRewards(false, state).whenComplete((ignored, failure) -> { + if (failure == null) outcome.complete(null); else outcome.completeExceptionally(failure); + }); + } catch (Throwable failure) { outcome.completeExceptionally(failure); } + }; try { - if (!plugin.isEnabled()) throw new IllegalStateException("Plugin disabled before offline replay"); - if (!plugin.getOptions().isProcessRewards()) { outcome.complete(null); return; } - if (isCheckWorld()) setCheckWorld(false); - dispatchOfflineRewards(false, state).whenComplete((ignored, failure) -> { - if (failure == null) outcome.complete(null); else outcome.completeExceptionally(failure); + // Shared-runtime admission and queue storage stay off the owner. + plugin.getTimer().execute(() -> { + try { + UserDataManager manager = sharedReplayDataManager(); + if (manager == null || !manager.deferSharedStorageWork(work)) work.run(); + } catch (Throwable failure) { handoff.completeExceptionally(failure); } }); - } catch (Throwable failure) { outcome.completeExceptionally(failure); } - }; - try { - // Runtime admission and queue storage must also stay off the owner. - plugin.getTimer().execute(() -> { - try { - UserDataManager manager = sharedReplayDataManager(); - if (manager == null || !manager.deferSharedStorageWork(work)) work.run(); - } catch (Throwable failure) { handoff.completeExceptionally(failure); } - }); - } catch (Throwable failure) { handoff.completeExceptionally(failure); } - return handoff.orTimeout(30, TimeUnit.SECONDS).thenCompose(stage -> stage); - }); + } catch (Throwable failure) { handoff.completeExceptionally(failure); } + return handoff.orTimeout(30, TimeUnit.SECONDS).thenCompose(stage -> stage); + }); + // Cancelling the public result must not suppress a successful owner capture's + // continuation: that continuation still owns admission and durable replay. + return owned.thenApply(ignored -> null); + } catch (RuntimeException | Error failure) { + admission.close(); + return CompletableFuture.failedFuture(failure); + } } /** Snapshot of local replay ownership only; never reads user storage. */ @@ -1331,18 +1378,34 @@ private void checkOfflineRewards(ReplayPlayerState capturedState) { plugin.debug("Processing rewards is disabled"); return; } - UserDataManager manager = sharedReplayDataManager(); - if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { - ReplayPlayerState state = captureReplayPlayerState(); - if (manager.deferSharedStorageWork(() -> checkOfflineRewards(state))) return; + RewardHandler.PersistedReplayCheck admission = plugin.getRewardHandler().admitPersistedReplayCheck(); + if (admission == null) return; + boolean deferred = false; + try { + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + deferred = manager.deferSharedStorageWork(() -> { + try (admission) { checkAdmittedOfflineRewards(false, state); } + }); + if (deferred) return; + } + checkAdmittedOfflineRewards(false, capturedState); + } finally { + if (!deferred) admission.close(); } - if (isCheckWorld()) { - setCheckWorld(false); + } + + private CompletionStage checkAdmittedOfflineRewards(boolean force, ReplayPlayerState capturedState) { + if (!plugin.getOptions().isProcessRewards()) { + plugin.debug("Processing rewards is disabled"); + return CompletableFuture.completedFuture(null); } - dispatchOfflineRewards(false, capturedState); + if (force || isCheckWorld()) setCheckWorld(false); + return dispatchAdmittedOfflineRewards(force, capturedState); } - private CompletionStage dispatchOfflineRewards(boolean force, ReplayPlayerState capturedState) { + private CompletionStage dispatchAdmittedOfflineRewards(boolean force, ReplayPlayerState capturedState) { ArrayList rewards = new ArrayList<>(getOfflineRewards()); ArrayList> completions = new ArrayList<>(); for (String rewardEntry : rewards) { @@ -1702,32 +1765,38 @@ private CompletionStage forceRunOfflineRewardsAsync(ReplayPlayerState capt plugin.debug("Processing rewards is disabled"); return CompletableFuture.completedFuture(null); } - UserDataManager manager = sharedReplayDataManager(); - if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { - ReplayPlayerState state = captureReplayPlayerState(); - CompletableFuture completion = new CompletableFuture<>(); + RewardHandler.PersistedReplayCheck admission = plugin.getRewardHandler().admitPersistedReplayCheck(); + if (admission == null) return CompletableFuture.completedFuture(null); + boolean deferred = false; + try { + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + CompletableFuture completion = new CompletableFuture<>(); + try { + deferred = manager.deferSharedStorageWork(() -> { + try (admission) { + checkAdmittedOfflineRewards(true, state).whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + if (deferred) return completion; + } catch (RuntimeException | Error failure) { + completion.completeExceptionally(failure); + return completion; + } + } try { - if (manager.deferSharedStorageWork(() -> { - try { - forceRunOfflineRewardsAsync(state).whenComplete((ignored, failure) -> { - if (failure == null) completion.complete(null); - else completion.completeExceptionally(failure); - }); - } catch (Throwable failure) { - completion.completeExceptionally(failure); - } - })) return completion; + return checkAdmittedOfflineRewards(true, capturedState); } catch (RuntimeException | Error failure) { - completion.completeExceptionally(failure); - return completion; + return CompletableFuture.failedFuture(failure); } - } - - setCheckWorld(false); - try { - return dispatchOfflineRewards(true, capturedState); - } catch (RuntimeException | Error failure) { - return CompletableFuture.failedFuture(failure); + } finally { + if (!deferred) admission.close(); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java index 69da17a1d2..862264a5ac 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java @@ -49,6 +49,8 @@ public class UserDataManager { @Getter private ArrayList booleanColumns; @Getter private AdvancedCorePlugin plugin; @Getter private ScheduledExecutorService timer; + /** Exact private executor identity; Spigot reports every thread as primary after stop. */ + private volatile Thread storageWorker; @Getter private ConcurrentHashMap userDataCache; private volatile Consumer sharedCacheInitializer; @@ -468,6 +470,10 @@ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable af // A second shutdown caller must not interpret an in-flight retirement as // "no runtime" and close the native provider underneath its final flush. if (runtime == null) return sharedRuntimeRetiring ? sharedRuntimeRetirement : null; + // Fence queued notifications before closeAsync marks the runtime retiring. + // An already-running callback retains its lifecycle read admission and + // finishes before the runtime's final writer can close storage. + closeSharedUserDataNotifications(); sharedRuntime = null; sharedRuntimeRetiring = true; completion = new CompletableFuture<>(); @@ -646,6 +652,7 @@ public UserDataManager(AdvancedCorePlugin plugin) { // this component-owned worker daemon prevents that hung call from retaining // the server JVM after the bounded lifecycle watchdog has reported it. worker.setDaemon(true); + storageWorker = worker; return worker; }); loadKeys(); @@ -1360,6 +1367,9 @@ public boolean mustDeferSharedStorageAccess() { * platform-owned execution lanes and must never wait on JDBC/shared-runtime admission. */ public boolean isPlatformOwnedThread() { + // This thread is created by our single-threaded executor, never by Bukkit. + // Do not infer ownership from its name, plugin enabled state, or shutdown state. + if (Thread.currentThread() == storageWorker) return false; Object server = Bukkit.getServer(); if (server == null) return false; try { if (Bukkit.isPrimaryThread()) return true; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java index 9833e5a894..c5b9ccd622 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java @@ -1,37 +1,109 @@ package com.bencodez.advancedcore.bukkit.runtime; import java.util.List; +import java.util.ArrayList; +import java.util.IdentityHashMap; import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.function.LongSupplier; +import java.util.function.Function; +import java.util.concurrent.Executor; import org.bukkit.Bukkit; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; import com.bencodez.advancedcore.api.javascript.JavascriptEngineHandler; +import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.advancedcore.core.platform.RuntimePlatform; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.data.ServerData; /** Bukkit services for the shared executor lifecycle; no duplicate owners are created. */ public final class BukkitRuntimePlatform implements RuntimePlatform { private final AdvancedCorePlugin plugin; + private final RewardHandler rewardHandler; + private volatile LongSupplier shutdownRemainingNanos; + private final CompletableFuture persistenceWorkerTermination = new CompletableFuture<>(); + // The hook can be admitted later, after a retained time transition drains. + private final CompletableFuture consumerPreparationCompletion = new CompletableFuture<>(); + private final CompletableFuture unloadHookCompletion = new CompletableFuture<>(); + private final CompletableFuture shutdownHookCompletion = new CompletableFuture<>(); + private final List nativeExecutorOwners; + // Exactly three existing final saves, in order, with no server-thread I/O. + private final ThreadPoolExecutor persistenceWorker = new ThreadPoolExecutor(1, 1, 0L, + TimeUnit.MILLISECONDS, new ArrayBlockingQueue<>(3), + AdvancedCoreRuntime.daemonThreads("AdvancedCore-Shutdown-Persistence")) { + @Override protected void terminated() { persistenceWorkerTermination.complete(null); } + }; + private volatile CompletionStage inventoryPersistence = CompletableFuture.completedFuture(null); + private volatile CompletionStage timestampPersistence = CompletableFuture.completedFuture(null); + private volatile CompletionStage permissionPersistence = CompletableFuture.completedFuture(null); + private CompletionStage finalPublicationOrder = CompletableFuture.completedFuture(null); private volatile CompletionStage userStorageRetirement = CompletableFuture.completedFuture(null); private CompletionStage startedUserStorageRetirement; + private volatile CompletionStage admittedUserStorageRetirement; + private volatile CompletionStage delayedRewardRetirement = CompletableFuture.completedFuture(null); + private CompletionStage preStorageShutdownHook; + private CompletableFuture storageRetirementPreparation = CompletableFuture.completedFuture(null); private volatile CompletionStage timeChangeRetirement = CompletableFuture.completedFuture(null); private final Object userStorageRetirementLock = new Object(); + private record UserStorageAdmission(boolean enabled, UserManager users, UserDataManager manager) { } + private volatile UserStorageAdmission userStorageAdmission; private final AtomicBoolean userStorageOwnerClosed = new AtomicBoolean(); + private final java.util.concurrent.atomic.AtomicReference> asynchronousOwnerClose = + new java.util.concurrent.atomic.AtomicReference<>(); public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); + rewardHandler = plugin.getRewardHandler(); + // Capture identities before consumer unload changes handler/manager fields. + var owners = new IdentityHashMap(); + addExecutor(owners, plugin.getTimer()); + addExecutor(owners, plugin.getLoginTimer()); + addExecutor(owners, plugin.getInventoryTimer()); + TimeChecker checker = plugin.getTimeChecker(); + if (checker != null) addExecutor(owners, checker.getTimer()); + FullInventoryHandler inventory = plugin.getFullInventoryHandler(); + if (inventory != null) addExecutor(owners, inventory.getTimer()); + if (plugin.getPermissionHandler() != null) addExecutor(owners, plugin.getPermissionHandler().getTimer()); + if (rewardHandler != null) addExecutor(owners, rewardHandler.getDelayedTimer()); + UserManager users = plugin.getLoadedUserManager(); + if (users != null && users.getDataManager() != null) addExecutor(owners, users.getDataManager().getTimer()); + // Keep the same non-creating snapshot for retirement that was used to + // discover the native executor owners. Shutdown may clear the plugin's + // manager reference before the storage cleanup action runs; looking it + // up again would both lose the original owner and invoke a lifecycle + // callback twice. + userStorageAdmission = new UserStorageAdmission(plugin.isLoadUserData(), users, + users == null ? null : users.getDataManager()); + nativeExecutorOwners = List.copyOf(owners.keySet()); + } + + private static void addExecutor(IdentityHashMap owners, + ScheduledExecutorService executor) { + if (executor != null) owners.put(executor, Boolean.TRUE); + } + + @Override public void beginShutdown(LongSupplier remainingNanos) { + shutdownRemainingNanos = Objects.requireNonNull(remainingNanos, "remainingNanos"); } @Override public ScheduledExecutorService getTimer() { return plugin.getTimer(); } @Override public ScheduledExecutorService getUserStorageTimer() { + UserStorageAdmission admission = userStorageAdmission; + if (admission != null) return admission.manager() == null ? null : admission.manager().getTimer(); UserManager users = plugin.getLoadedUserManager(); return users == null || users.getDataManager() == null ? null : users.getDataManager().getTimer(); } @@ -45,8 +117,11 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List beforeExecutorShutdown() { return List.of( new Cleanup("full inventory handler", () -> { - FullInventoryHandler handler = plugin.getFullInventoryHandler(); - if (handler != null) handler.shutdown(); + inventoryPersistence = finalPersistence("full inventory snapshot", executor -> { + FullInventoryHandler handler = plugin.getFullInventoryHandler(); + return handler == null ? CompletableFuture.completedFuture(null) + : Objects.requireNonNull(handler.shutdownAsync(executor), "inventory persistence"); + }); }), new Cleanup("Javascript engine", () -> { if (plugin.getOptions() != null && plugin.getOptions().isJavascriptEngineEnabled()) { @@ -54,14 +129,23 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { JavascriptEngineHandler.getInstance().clearCachedEngine(); } }), + new Cleanup("delayed reward admission", this::closeDelayedRewardAdmission), new Cleanup("time change admission", this::closeTimeChangeAdmission), new Cleanup("user storage", this::closeUserStorageAfterTimeChangeRetirement), new Cleanup("server data timestamp", () -> { - if (plugin.getServerDataFile() != null) plugin.getServerDataFile().setLastUpdated(); + timestampPersistence = finalPersistence("server data timestamp", this::submitTimestampPersistence); })); } @Override public CompletionStage beforeExecutorShutdownCompletion() { return userStorageRetirement; } + @Override public CompletionStage admittedStorageRetirementCompletion() { + return admittedUserStorageRetirement; + } + @Override public CompletionStage storageRetirementPreparationCompletion() { + synchronized (userStorageRetirementLock) { + return storageRetirementPreparation; + } + } @Override public boolean holdTimeTimerUntilPreExecutorShutdownCompletion() { return timeChangeRetirement != null && !timeChangeRetirement.toCompletableFuture().isDone(); } @@ -72,7 +156,10 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { } @Override public void beforeDeferredPlatformCleanup() { TimeChecker checker = plugin.getTimeChecker(); - if (checker != null) checker.cancelActiveTransitionsAndClosePersistence(); + if (checker != null) { + checker.cancelActiveTransitionsAndClosePersistence(); + checker.cancelQueuedUpdate(); + } } @Override public boolean canBlockForPreExecutorShutdown() { return Bukkit.getServer() == null || !Bukkit.isPrimaryThread(); @@ -80,7 +167,12 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List afterExecutorGrace() { return List.of(new Cleanup("reward handler", () -> { - if (plugin.getRewardHandler() != null) plugin.getRewardHandler().shutdown(); + if (rewardHandler != null) { + LongSupplier budget = shutdownRemainingNanos; + if (budget == null) rewardHandler.shutdown(); + else rewardHandler.shutdown(Math.min(TimeUnit.SECONDS.toNanos(10), + Math.max(0L, budget.getAsLong())), TimeUnit.NANOSECONDS); + } })); } @@ -89,14 +181,22 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { CompletionStage retirement = userStorageRetirement; if (retirement != null && (!retirement.toCompletableFuture().isDone() || retirement.toCompletableFuture().isCompletedExceptionally())) { - closeCurrentUserStorageOwner(plugin.getLoadedUserManager()); + observePersistence("terminal user storage owner", closeUserStorageOwnerAsync(captureUserStorageAdmission())); } })); } @Override public List afterExecutorShutdown() { return List.of( - new Cleanup("plugin unload hook", plugin::onUnLoad), + new Cleanup("plugin unload hook", () -> { + try { + plugin.onUnLoad(); + unloadHookCompletion.complete(null); + } catch (Throwable failure) { + unloadHookCompletion.completeExceptionally(failure); + throw failure; + } + }), new Cleanup("skull cache", () -> { if (plugin.getSkullCacheHandler() != null) plugin.getSkullCacheHandler().close(); }), @@ -104,10 +204,25 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { if (plugin.getHologramHandler() != null) plugin.getHologramHandler().onShutDown(); }), new Cleanup("permission handler", () -> { - if (plugin.getPermissionHandler() != null) plugin.getPermissionHandler().shutDown(); + permissionPersistence = finalPersistence("timed permission snapshot", executor -> + plugin.getPermissionHandler() == null ? CompletableFuture.completedFuture(null) + : Objects.requireNonNull(plugin.getPermissionHandler().shutDownAsync(executor), + "permission persistence")); }), new Cleanup("dialog service", () -> { if (plugin.getDialogService() != null) plugin.getDialogService().unregister(); + }), + new Cleanup("shutdown persistence worker", this::finishPersistenceSubmission), + new Cleanup("plugin shutdown completion hook", () -> { + try { + plugin.onShutdownComplete(finalFilePersistence()); + shutdownHookCompletion.complete(null); + } catch (Throwable failure) { + shutdownHookCompletion.completeExceptionally(failure); + throw failure; + } finally { + releaseNativeOwnership(); + } })); } @@ -118,55 +233,374 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { } private void closeTimeChangeAdmission() { - TimeChecker checker = plugin.getTimeChecker(); - timeChangeRetirement = checker == null ? CompletableFuture.completedFuture(null) : checker.beginShutdown(); + try { + TimeChecker checker = plugin.getTimeChecker(); + timeChangeRetirement = checker == null ? CompletableFuture.completedFuture(null) + : Objects.requireNonNull(checker.beginShutdown(), "time retirement"); + } catch (Throwable failure) { timeChangeRetirement = CompletableFuture.failedFuture(failure); } + } + + private void closeDelayedRewardAdmission() { + if (rewardHandler == null) return; + CompletionStage retirement = rewardHandler.beginShutdown(); + delayedRewardRetirement = retirement == null ? CompletableFuture.completedFuture(null) : retirement; } private void closeUserStorageAfterTimeChangeRetirement() { + // Deferred platform unload may change configuration or manager references. + // Storage already admitted for this shutdown retains its own retirement identity. + captureUserStorageAdmission(); CompletionStage timeRetirement = timeChangeRetirement; if (timeRetirement.toCompletableFuture().isDone()) { try { timeRetirement.toCompletableFuture().join(); userStorageRetirement = ensureUserStorageRetirementStarted(); - } catch (java.util.concurrent.CompletionException failure) { - userStorageRetirement = CompletableFuture.failedFuture(failure); + } catch (java.util.concurrent.CompletionException | java.util.concurrent.CancellationException failure) { + userStorageRetirement = preservePreparationFailure(ensureUserStorageRetirementStarted(), failure); } return; } - userStorageRetirement = timeRetirement.thenCompose(ignored -> ensureUserStorageRetirementStarted()); + userStorageRetirement = timeRetirement.handle((ignored, failure) -> failure).thenCompose(failure -> { + CompletionStage retirement = ensureUserStorageRetirementStarted(); + return failure == null ? retirement : preservePreparationFailure(retirement, failure); + }); } private CompletionStage ensureUserStorageRetirementStarted() { synchronized (userStorageRetirementLock) { if (startedUserStorageRetirement == null) { - startedUserStorageRetirement = closeUserStorageAfterSharedRetirement(); + CompletionStage hook = ensurePreStorageShutdownHook(); + if (hook.toCompletableFuture().isDone()) { + try { hook.toCompletableFuture().join(); } + catch (java.util.concurrent.CompletionException | java.util.concurrent.CancellationException failure) { + startedUserStorageRetirement = rewardHandler == null + ? retireAfterPreparationFailure(failure) : deferUserStorageRetirement(failure); + if (captureUserStorageAdmission().enabled()) admittedUserStorageRetirement = startedUserStorageRetirement; + return startedUserStorageRetirement; + } + // A delayed check may have returned after deferring itself to + // the manager. Its worker barrier must precede the replay-tail + // snapshot even when the consumer hook completed immediately. + startedUserStorageRetirement = rewardHandler == null + ? closeUserStorageAfterSharedRetirement() : deferUserStorageRetirement(null); + if (captureUserStorageAdmission().enabled()) admittedUserStorageRetirement = startedUserStorageRetirement; + } else { + var preparation = new CompletableFuture(); + storageRetirementPreparation = preparation; + startedUserStorageRetirement = hook.handle((ignored, failure) -> failure) + .thenCompose(failure -> { + try { return deferUserStorageRetirement(failure); } + finally { + // Future completion can precede its callbacks. Signal only + // after the final retirement task has been admitted. + preparation.complete(null); + } + }); + } } return startedUserStorageRetirement; } } + /** A completing consumer callback may still hold shared storage read admission. */ + private CompletionStage deferUserStorageRetirement(Throwable preparationFailure) { + UserStorageAdmission admission = captureUserStorageAdmission(); + if (!admission.enabled() || admission.users() == null) { + CompletionStage retirement = preparationFailure == null ? closeUserStorageAfterSharedRetirement() + : retireAfterPreparationFailure(preparationFailure); + if (admission.enabled()) admittedUserStorageRetirement = retirement; + return retirement; + } + var result = new CompletableFuture(); + // Publish the admission before handing the task to the worker. The + // lifecycle watchdog can run re-entrantly from an executor's execute + // implementation (and can otherwise race the return from execute). + // Reserving the completion first ensures it retains the worker and + // native owner even while the task is being accepted. If acceptance + // is rejected, the same stage is completed exceptionally below, so a + // rejection cannot masquerade as a successful retirement. + admittedUserStorageRetirement = result; + try { + Objects.requireNonNull(getUserStorageTimer(), "user storage timer").execute(() -> { + try { + CompletionStage replay = AdvancedCoreUser.persistedReplayCompletion(plugin); + if (replay.toCompletableFuture().isDone()) { + replay.toCompletableFuture().join(); + completeDeferredUserStorageRetirement(result, preparationFailure); + } else { + // Queue after acknowledgement/restore finishes. The + // completing callback may still hold shared read admission. + replay.whenComplete((ignored, failure) -> { + if (failure != null) { + result.completeExceptionally(preparationAndStorageFailure(preparationFailure, failure)); + return; + } + try { + getUserStorageTimer().execute(() -> completeDeferredUserStorageRetirement(result, + preparationFailure)); + } catch (RuntimeException | Error admissionFailure) { + result.completeExceptionally(preparationAndStorageFailure(preparationFailure, + admissionFailure)); + } + }); + } + } catch (Throwable failure) { + result.completeExceptionally(preparationAndStorageFailure(preparationFailure, failure)); + } + }); + } catch (RuntimeException | Error failure) { + // Do not detach the shared runtime or pretend rejected work flushed storage. + result.completeExceptionally(preparationAndStorageFailure(preparationFailure, failure)); + } + return result; + } + + private void completeDeferredUserStorageRetirement(CompletableFuture result, Throwable preparationFailure) { + try { + CompletionStage retirement = preparationFailure == null + ? closeUserStorageAfterSharedRetirement() : retireAfterPreparationFailure(preparationFailure); + retirement.whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(preparationAndStorageFailure(preparationFailure, failure)); + } + } + + private static Throwable preparationAndStorageFailure(Throwable preparationFailure, Throwable storageFailure) { + if (preparationFailure == null) return storageFailure; + Throwable cause = preparationFailure instanceof java.util.concurrent.CompletionException + && preparationFailure.getCause() != null ? preparationFailure.getCause() : preparationFailure; + var reported = new java.util.concurrent.CompletionException(cause); + if (storageFailure != null && storageFailure != cause) reported.addSuppressed(storageFailure); + return reported; + } + + /** A failed consumer preparation still has accepted cache writes to retire. */ + private CompletionStage retireAfterPreparationFailure(Throwable preparationFailure) { + try { return preservePreparationFailure(closeUserStorageAfterSharedRetirement(), preparationFailure); } + catch (Throwable storageFailure) { + return CompletableFuture.failedFuture(preparationAndStorageFailure(preparationFailure, storageFailure)); + } + } + + private CompletionStage preservePreparationFailure(CompletionStage retirement, Throwable preparationFailure) { + Throwable cause = preparationFailure instanceof java.util.concurrent.CompletionException + && preparationFailure.getCause() != null ? preparationFailure.getCause() : preparationFailure; + var reported = new java.util.concurrent.CompletionException(cause); + var result = new CompletableFuture(); + try { + retirement.whenComplete((ignored, storageFailure) -> { + if (storageFailure != null && storageFailure != cause) reported.addSuppressed(storageFailure); + result.completeExceptionally(reported); + }); + } catch (Throwable storageFailure) { + if (storageFailure != cause) reported.addSuppressed(storageFailure); + result.completeExceptionally(reported); + } + return result; + } + + private CompletionStage ensurePreStorageShutdownHook() { + synchronized (userStorageRetirementLock) { + if (preStorageShutdownHook != null) return preStorageShutdownHook; + try { + CompletionStage hook = plugin.onBeforeStorageShutdown(); + preStorageShutdownHook = hook == null ? CompletableFuture.completedFuture(null) : hook; + } catch (Throwable failure) { + preStorageShutdownHook = CompletableFuture.failedFuture(failure); + } + CompletableFuture inventory = inventoryPersistence.toCompletableFuture(); + if (!inventory.isDone() || inventory.isCompletedExceptionally()) { + preStorageShutdownHook = CompletableFuture.allOf(inventory, + preStorageShutdownHook.toCompletableFuture()); + } + CompletableFuture rewards = delayedRewardRetirement.toCompletableFuture(); + if (!rewards.isDone() || rewards.isCompletedExceptionally()) { + preStorageShutdownHook = CompletableFuture.allOf(rewards, + preStorageShutdownHook.toCompletableFuture()); + } + preStorageShutdownHook.whenComplete((ignored, failure) -> { + if (failure == null) consumerPreparationCompletion.complete(null); + else consumerPreparationCompletion.completeExceptionally(failure); + }); + return preStorageShutdownHook; + } + } + + private CompletionStage observePersistence(String component, CompletionStage completion) { + completion.whenComplete((ignored, failure) -> { + if (failure != null) cleanupFailed(component, failure); + }); + return completion; + } + + private CompletionStage finalPersistence(String component, + Function> submission) { + var completion = new CompletableFuture(); + CompletionStage predecessor = finalPublicationOrder; + finalPublicationOrder = completion; + Executor ordered = task -> predecessor.whenComplete((ignored, priorFailure) -> { + // Failure is still reported by its original stage; independent + // cleanup may proceed only after that owner has actually settled. + try { persistenceWorker.execute(task); } + catch (Throwable failure) { completion.completeExceptionally(failure); } + }); + try { + // Handler shutdown fences admission now, while its actual snapshot + // waits on the preceding publication through the supplied executor. + Objects.requireNonNull(submission.apply(ordered), "final persistence") + .whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + return observePersistence(component, completion); + } + + private CompletionStage submitTimestampPersistence(Executor executor) { + ServerData owner = plugin.getServerDataFile(); + if (owner == null || (!plugin.isLoadServerData() && owner.getData() == null)) { + return CompletableFuture.completedFuture(null); + } + long lastUpdated = System.currentTimeMillis(); + var completion = new CompletableFuture(); + try { + executor.execute(() -> { + try { + owner.mutateFinalData(data -> data.set("LastUpdated", lastUpdated)) + .whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); + else completion.completeExceptionally(failure); + }); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + }); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + return completion; + } + + private void releaseNativeOwnership() { + if (!plugin.hasServerDataOwnership()) return; + CompletableFuture settled = CompletableFuture.allOf(finalFilePersistence(), consumerPreparationCompletion, + userStorageRetirement.toCompletableFuture(), persistenceWorkerTermination, unloadHookCompletion, + shutdownHookCompletion); + // A cancelled/failed publication or preparation can settle while another + // writer is still active. It cannot prove drain, even after eventual timer + // termination; retain the opaque admission token until JVM restart. + plugin.releaseServerDataOwnershipAfter(settled.thenCompose(ignored -> nativeExecutorTermination())); + } + + private CompletionStage nativeExecutorTermination() { + List pending = new ArrayList<>(); + for (ScheduledExecutorService owner : nativeExecutorOwners) { + if (!owner.isTerminated()) pending.add(owner); + } + if (pending.isEmpty()) return CompletableFuture.completedFuture(null); + var completion = new CompletableFuture(); + // Cancellation and lifecycle-drain acknowledgements are not executor + // termination. This daemon waits outside the server thread and never + // refreshes the caller's shared 15-second wait budget. + Thread waiter = AdvancedCoreRuntime.daemonThreads("AdvancedCore-ServerData-Drain").newThread(() -> { + try { + for (ScheduledExecutorService owner : pending) { + while (!owner.isTerminated()) owner.awaitTermination(1, TimeUnit.SECONDS); + } + completion.complete(null); + } catch (Throwable failure) { + if (failure instanceof InterruptedException) Thread.currentThread().interrupt(); + completion.completeExceptionally(failure); + } + }); + try { waiter.start(); } + catch (Throwable failure) { completion.completeExceptionally(failure); } + return completion; + } + + private CompletableFuture finalFilePersistence() { + return CompletableFuture.allOf(inventoryPersistence.toCompletableFuture(), + timestampPersistence.toCompletableFuture(), permissionPersistence.toCompletableFuture()); + } + + private void finishPersistenceSubmission() { + // Do not discard accepted snapshots on timeout or close the native owner + // while inventory delivery can still advance its durable replay receipt. + CompletableFuture completion = finalFilePersistence(); + // Asynchronous owners can settle after this lifecycle callback returns. + // Keep the existing worker available for their ordered successors. + completion.whenComplete((ignored, failure) -> persistenceWorker.shutdown()); + if (completion.isDone()) return; + LongSupplier budget = shutdownRemainingNanos; + long remaining = budget == null ? TimeUnit.SECONDS.toNanos(15) : Math.max(0L, budget.getAsLong()); + CompletableFuture.delayedExecutor(remaining, TimeUnit.NANOSECONDS).execute(() -> { + if (!completion.isDone()) cleanupFailed("shutdown persistence", new java.util.concurrent.TimeoutException( + "Shutdown persistence is still pending after the shared 15-second deadline")); + }); + } + private CompletionStage closeUserStorageAfterSharedRetirement() { - if (!plugin.isLoadUserData()) { + UserStorageAdmission admission = captureUserStorageAdmission(); + if (!admission.enabled()) { return CompletableFuture.completedFuture(null); } - UserManager users = plugin.getLoadedUserManager(); // Resolve this only after shared retirement. A successful replacement may // install a new owner while shutdown is waiting for its worker, and closing // the owner observed before that wait leaks the installed MySQL provider. - Runnable closeMysql = () -> closeCurrentUserStorageOwner(users); - if (users == null) { - closeMysql.run(); - return CompletableFuture.completedFuture(null); + Runnable closeMysql = () -> closeCurrentUserStorageOwner(admission); + if (admission.users() == null) { + return closeUserStorageOwnerAsync(admission); } - CompletionStage retirement = users.getDataManager().closeSharedRuntimeAsyncCompletion(closeMysql); + CompletionStage retirement = admission.manager().closeSharedRuntimeAsyncCompletion(closeMysql); if (retirement == null) { - closeMysql.run(); - return CompletableFuture.completedFuture(null); + return closeUserStorageOwnerAsync(admission); } return retirement; } - private void closeCurrentUserStorageOwner(UserManager users) { + /** A provider close can itself block in JDBC even when no manager worker remains. */ + private CompletionStage closeUserStorageOwnerAsync(UserStorageAdmission admission) { + CompletableFuture completion = new CompletableFuture<>(); + while (true) { + CompletableFuture active = asynchronousOwnerClose.get(); + if (active != null) return active; + if (asynchronousOwnerClose.compareAndSet(null, completion)) break; + } + Thread worker = AdvancedCoreRuntime.daemonThreads("AdvancedCore-Storage-Owner-Close").newThread(() -> { + try { + // Without a manager there is no worker barrier to run the + // snapshot. This existing daemon owner-close worker can drain + // callbacks without waiting on the lifecycle caller. + AdvancedCoreUser.persistedReplayCompletion(plugin).toCompletableFuture().join(); + closeCurrentUserStorageOwner(admission); + completion.complete(null); + } + catch (Throwable failure) { + asynchronousOwnerClose.compareAndSet(completion, null); + completion.completeExceptionally(failure); + } + }); + try { worker.start(); } + catch (Throwable failure) { + asynchronousOwnerClose.compareAndSet(completion, null); + completion.completeExceptionally(failure); + } + return completion; + } + + private UserStorageAdmission captureUserStorageAdmission() { + synchronized (userStorageRetirementLock) { + if (userStorageAdmission == null) { + boolean enabled = plugin.isLoadUserData(); + // Even disabled user loading can have an existing worker to retire. + UserManager users = plugin.getLoadedUserManager(); + userStorageAdmission = new UserStorageAdmission(enabled, users, + users == null ? null : users.getDataManager()); + } + return userStorageAdmission; + } + } + + private void closeCurrentUserStorageOwner(UserStorageAdmission admission) { if (!userStorageOwnerClosed.compareAndSet(false, true)) return; try { AdvancedCorePlugin.UserStorageOwner owner = plugin.getNativeUserStorageOwner(); @@ -176,8 +610,8 @@ else if (owner.storageType() == UserStorage.SQLITE && owner.table() != null && owner.table().getSqLite() != null) owner.table().getSqLite().closeConnection(); return; } - boolean ownsMysql = users != null && users.getDataManager().hasSharedSqlBackend() - ? users.getDataManager().usesSharedSqlStorage(UserStorage.MYSQL) + boolean ownsMysql = admission.users() != null && admission.manager().hasSharedSqlBackend() + ? admission.manager().usesSharedSqlStorage(UserStorage.MYSQL) : plugin.getOptions() != null && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()); if (ownsMysql && plugin.getMysql() != null) plugin.getMysql().close(); } catch (RuntimeException | Error failure) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java index 6b561f6e3c..9ea7a707aa 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java @@ -12,7 +12,6 @@ import java.util.function.BiConsumer; import java.util.function.Consumer; -import org.bukkit.Bukkit; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -156,7 +155,7 @@ private void bind(UserDataCache cache, UUID uuid) { } @Override public void requireBlockingAllowed() { - if ((Bukkit.getServer() != null && Bukkit.isPrimaryThread()) || manager.isPlatformOwnedThread()) { + if (manager.isPlatformOwnedThread()) { throw new IllegalStateException("Shared user storage must run on a worker; use closeAsync for shutdown"); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java index fe9db5280c..16cfc60596 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java @@ -5,9 +5,13 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; +import java.util.function.LongSupplier; /** Supplies existing executor owners and platform cleanup without exposing game APIs. */ public interface RuntimePlatform { + /** One shared monotonic remaining-time budget, measured from entry into disable. */ + default void beginShutdown(LongSupplier remainingNanos) { } + record Cleanup(String name, Runnable action) { public Cleanup { Objects.requireNonNull(name, "name"); @@ -39,7 +43,25 @@ default CompletionStage beforeExecutorShutdownCompletion() { */ default boolean canBlockForPreExecutorShutdown() { return true; } - /** Maximum time a non-blocking lifecycle waits before forcing its storage worker. */ + /** + * Consumer preparation and final-task admission that must settle before the storage worker can stop. + * A watchdog may report a timeout while this stage is pending, but must retain + * storage admission and the native provider for its eventual final checkpoint. + * The default has no separate preparation phase. + */ + default CompletionStage storageRetirementPreparationCompletion() { + return CompletableFuture.completedFuture(null); + } + + /** + * Actual accepted native-storage retirement, or {@code null} when no such task + * has been admitted. An incomplete stage retains its daemon storage worker and + * provider until it settles, even after the caller's deadline. A pending time + * transition or consumer hook alone is not native-task admission. + */ + default CompletionStage admittedStorageRetirementCompletion() { return null; } + + /** Watchdog grace capped by the disable deadline; pending preparation retains its durable owner. */ default long deferredShutdownTimeoutMillis() { return 5_000; } /** diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index dd6c737e3d..da6415ec90 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -11,6 +11,8 @@ import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.LongSupplier; +import java.util.function.BiConsumer; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -23,12 +25,21 @@ */ public final class AdvancedCoreRuntime { private static final long PRE_SHUTDOWN_WAIT_SECONDS = 5; + private static final long SHUTDOWN_BUDGET_NANOS = TimeUnit.SECONDS.toNanos(15); private final RuntimePlatform platform; + private final LongSupplier nanoTime; + private volatile long shutdownStartedNanos; private enum CleanupState { SUCCESS, FAILURE, DEFERRED } private record ExecutorGrace(ScheduledExecutorService timeTimer) { } public AdvancedCoreRuntime(RuntimePlatform platform) { + this(platform, System::nanoTime); + } + + /** Clock injection keeps aggregate-deadline tests independent of wall-clock sleeps. */ + public AdvancedCoreRuntime(RuntimePlatform platform, LongSupplier nanoTime) { this.platform = Objects.requireNonNull(platform, "platform"); + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime"); } /** The same three independent executor owners used by the Bukkit plugin. */ @@ -39,8 +50,8 @@ public static ExecutorGroup createExecutors() { ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(daemonThreads("AdvancedCore-Storage")); ScheduledExecutorService loginTimer = null; try { - loginTimer = Executors.newSingleThreadScheduledExecutor(); - return new ExecutorGroup(timer, loginTimer, Executors.newSingleThreadScheduledExecutor()); + loginTimer = Executors.newSingleThreadScheduledExecutor(daemonThreads("AdvancedCore-Login")); + return new ExecutorGroup(timer, loginTimer, Executors.newSingleThreadScheduledExecutor(daemonThreads("AdvancedCore-Inventory"))); } catch (RuntimeException | Error failure) { timer.shutdownNow(); if (loginTimer != null) loginTimer.shutdownNow(); @@ -48,7 +59,7 @@ public static ExecutorGroup createExecutors() { } } - private static java.util.concurrent.ThreadFactory daemonThreads(String name) { + public static java.util.concurrent.ThreadFactory daemonThreads(String name) { AtomicInteger sequence = new AtomicInteger(); return task -> { Thread thread = new Thread(task, name + "-" + sequence.incrementAndGet()); @@ -62,6 +73,13 @@ private static java.util.concurrent.ThreadFactory daemonThreads(String name) { } public void shutdown() { + shutdown(nanoTime.getAsLong()); + } + + /** The Bukkit facade supplies the timestamp captured at plugin-disable entry. */ + public void shutdown(long startedNanos) { + shutdownStartedNanos = startedNanos; + platform.beginShutdown(this::remainingShutdownNanos); clean(platform.beforeExecutorShutdown()); CompletionStage retirement = platform.beforeExecutorShutdownCompletion(); CleanupState retirementState = awaitCleanup(retirement, "pre-executor shutdown"); @@ -89,10 +107,10 @@ private ExecutorGrace beginExecutorGrace(boolean stopStorageTimer) { shutdown(platform.getInventoryTimer()); platform.info("Allowing background tasks to finish before shutdown"); - await(platform.getLoginTimer(), 2, TimeUnit.SECONDS); - if (stopStorageTimer) await(platform.getTimer(), 2, TimeUnit.SECONDS); - await(timeTimer, 2, TimeUnit.SECONDS); - await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(platform.getLoginTimer(), 2, TimeUnit.SECONDS); + if (stopStorageTimer) awaitWithinBudget(platform.getTimer(), 2, TimeUnit.SECONDS); + awaitWithinBudget(timeTimer, 2, TimeUnit.SECONDS); + awaitWithinBudget(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); return new ExecutorGrace(timeTimer); } @@ -104,16 +122,16 @@ private void finishAfterExecutorGrace(ExecutorGrace grace, boolean storageTimerA platform::beforeForcedTimeTimerShutdown))); shutdownNow(grace.timeTimer()); shutdownNow(platform.getInventoryTimer()); - await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); - await(platform.getTimer(), 1, TimeUnit.SECONDS); - await(grace.timeTimer(), 1, TimeUnit.SECONDS); - await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(platform.getLoginTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(platform.getTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(grace.timeTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); ScheduledExecutorService storageTimer = platform.getUserStorageTimer(); if (storageTimer != null && storageTimer != platform.getTimer()) { shutdown(storageTimer); - await(storageTimer, 2, TimeUnit.SECONDS); + awaitWithinBudget(storageTimer, 2, TimeUnit.SECONDS); if (!storageTimer.isTerminated()) shutdownNow(storageTimer); - await(storageTimer, 1, TimeUnit.SECONDS); + awaitWithinBudget(storageTimer, 1, TimeUnit.SECONDS); } finishTerminalStorageCleanup(storageTimer, new AtomicBoolean()); clean(platform.afterExecutorShutdown()); @@ -143,7 +161,7 @@ private CleanupState awaitCleanup(CompletionStage completion, String compo } try { if (future.isDone()) future.join(); - else future.get(PRE_SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS); + else future.get(Math.min(TimeUnit.SECONDS.toNanos(PRE_SHUTDOWN_WAIT_SECONDS), remainingShutdownNanos()), TimeUnit.NANOSECONDS); return CleanupState.SUCCESS; } catch (TimeoutException timeout) { platform.cleanupFailed(component, timeout); @@ -174,34 +192,45 @@ private void finishDeferredCleanup(CompletionStage completion, String comp final ScheduledExecutorService storageTimer = timer; AtomicBoolean finished = new AtomicBoolean(); AtomicBoolean terminalStorageCleanup = new AtomicBoolean(); + AtomicBoolean retirementFailureReported = new AtomicBoolean(); if (holdTimeTimer) clean(List.of(new Cleanup("time change cancellation", platform::beforeDeferredPlatformCleanup))); // Thread-confined Bukkit cleanup must remain on the lifecycle thread. An // active transition is first cancelled above and its lease still gates // storage retirement through completion. finishDeferredPlatformCleanup(grace, storageTimer, holdTimeTimer); - completion.whenComplete((ignored, failure) -> { + BiConsumer retirementFinished = (ignored, failure) -> { + if (failure != null && retirementFailureReported.compareAndSet(false, true)) { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() : failure; + platform.cleanupFailed(component, cause); + } if (!finished.compareAndSet(false, true)) { if (failure != null) finishDeferredStorageTimer(storageTimer, true, true, terminalStorageCleanup); return; } if (failure == null) shutdown(storageTimer); else { - Throwable cause = failure instanceof CompletionException && failure.getCause() != null - ? failure.getCause() : failure; - platform.cleanupFailed(component, cause); shutdownNow(storageTimer); } if (holdTimeTimer) shutdownNow(grace.timeTimer()); finishDeferredStorageTimer(storageTimer, failure != null, failure != null, terminalStorageCleanup); - }); + }; + completion.whenComplete(retirementFinished); long timeoutMillis = Math.max(1, platform.deferredShutdownTimeoutMillis()); - Runnable timeout = () -> { - // Mark a still-draining transition recoverable before racing its final - // lease acknowledgement. Once this hook returns, it cannot advance a - // time marker even if the worker ignores interruption briefly. - if (holdTimeTimer) clean(List.of(new Cleanup("forced time transition shutdown", - platform::beforeForcedTimeTimerShutdown))); + Runnable forceRetirement = () -> { + if (finished.get()) return; + CompletionStage admitted = platform.admittedStorageRetirementCompletion(); + if (admitted != null) { + platform.cleanupFailed(component, new TimeoutException( + "Deferred storage retirement exceeded " + timeoutMillis + + " ms; retaining accepted retirement until completion")); + // The accepted barrier/final flush owns this daemon worker. A + // deadline or an interruption request cannot prove its completion, + // and shutting the worker down can discard a queued final task. + admitted.whenComplete(retirementFinished); + return; + } if (!finished.compareAndSet(false, true)) return; platform.cleanupFailed(component, new TimeoutException( "Deferred storage retirement exceeded " + timeoutMillis + " ms")); @@ -212,7 +241,39 @@ private void finishDeferredCleanup(CompletionStage completion, String comp shutdown(storageTimer); finishDeferredStorageTimer(storageTimer, false, true, terminalStorageCleanup); }; - try { CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(timeout); } + Runnable timeout = () -> { + if (finished.get()) return; + // Mark a still-draining time transition recoverable before racing its + // final lease acknowledgement. This may start consumer preparation. + if (holdTimeTimer) { + clean(List.of(new Cleanup("forced time transition shutdown", + platform::beforeForcedTimeTimerShutdown))); + // Consumer preparation retains only storage ownership. Its completion + // must not delay interruption of the fenced calendar worker. + shutdownNow(grace.timeTimer()); + } + CompletionStage preparation = platform.storageRetirementPreparationCompletion(); + if (preparation != null && !preparation.toCompletableFuture().isDone()) { + platform.cleanupFailed(component, new TimeoutException( + "Consumer storage preparation exceeded " + timeoutMillis + + " ms; retaining storage until preparation completes")); + // Keep the daemon storage worker open for the accepted checkpoint. + // A late admission must not immediately discard its newly queued final + // flush just because the caller's deadline already expired. Its actual + // retirement stage above still owns graceful shutdown and provider close. + preparation.whenComplete((ignored, failure) -> { + if (remainingShutdownNanos() > 0L) scheduleStorageWatchdog(timeoutMillis, forceRetirement); + }); + return; + } + forceRetirement.run(); + }; + scheduleStorageWatchdog(timeoutMillis, timeout); + } + + private void scheduleStorageWatchdog(long timeoutMillis, Runnable timeout) { + long delayNanos = Math.min(TimeUnit.MILLISECONDS.toNanos(timeoutMillis), remainingShutdownNanos()); + try { CompletableFuture.delayedExecutor(delayNanos, TimeUnit.NANOSECONDS).execute(timeout); } catch (RuntimeException | Error schedulingFailure) { platform.cleanupFailed("deferred storage shutdown watchdog", schedulingFailure); timeout.run(); @@ -226,17 +287,17 @@ private void finishDeferredPlatformCleanup(ExecutorGrace grace, ScheduledExecuto if (platform.getTimer() != storageTimer) shutdownNow(platform.getTimer()); if (!holdTimeTimer) shutdownNow(grace.timeTimer()); shutdownNow(platform.getInventoryTimer()); - await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); - if (platform.getTimer() != storageTimer) await(platform.getTimer(), 1, TimeUnit.SECONDS); - if (!holdTimeTimer) await(grace.timeTimer(), 1, TimeUnit.SECONDS); - await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(platform.getLoginTimer(), 1, TimeUnit.SECONDS); + if (platform.getTimer() != storageTimer) awaitWithinBudget(platform.getTimer(), 1, TimeUnit.SECONDS); + if (!holdTimeTimer) awaitWithinBudget(grace.timeTimer(), 1, TimeUnit.SECONDS); + awaitWithinBudget(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); clean(platform.afterExecutorShutdown()); } private void finishDeferredStorageTimer(ScheduledExecutorService timer, boolean forced, boolean runTerminalCleanup, AtomicBoolean terminalStorageCleanup) { Runnable retirement = () -> { - await(timer, forced ? 1 : 2, TimeUnit.SECONDS); + awaitWithinBudget(timer, forced ? 1 : 2, TimeUnit.SECONDS); if (!forced && timer != null && !timer.isTerminated()) shutdownNow(timer); if (runTerminalCleanup) finishTerminalStorageCleanup(timer, terminalStorageCleanup); }; @@ -268,6 +329,19 @@ private void finishTerminalStorageCleanup(ScheduledExecutorService timer, Atomic public static void shutdown(ScheduledExecutorService executor) { if (executor != null && !executor.isShutdown()) executor.shutdown(); } + + private long remainingShutdownNanos() { + long elapsed = Math.max(0L, nanoTime.getAsLong() - shutdownStartedNanos); + return elapsed >= SHUTDOWN_BUDGET_NANOS ? 0L : SHUTDOWN_BUDGET_NANOS - elapsed; + } + + private void awaitWithinBudget(ScheduledExecutorService executor, long timeout, TimeUnit unit) { + if (executor == null) return; + long remaining = remainingShutdownNanos(); + if (remaining == 0L) return; + if (remaining >= unit.toNanos(timeout)) await(executor, timeout, unit); + else await(executor, remaining, TimeUnit.NANOSECONDS); + } public static void shutdownNow(ScheduledExecutorService executor) { if (executor != null && !executor.isTerminated()) executor.shutdownNow(); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java index b6f92e01f9..1f2e6c4aa0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/data/ServerData.java @@ -14,19 +14,27 @@ import java.security.NoSuchAlgorithmException; import java.time.Month; import java.util.HexFormat; - + import org.bukkit.plugin.Plugin; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.simpleapi.file.DurableFiles; import com.bencodez.simpleapi.file.YMLFile; - -// TODO: Auto-generated Javadoc -/** - * The Class ServerData. - */ + +// TODO: Auto-generated Javadoc +/** + * The Class ServerData. + */ public class ServerData extends YMLFile { + // Selected only while the checked publisher owns this monitor. Publication + // visibility and its durable acknowledgement are separate outcomes. + private org.bukkit.configuration.file.FileConfiguration finalDataSnapshot; + private long visiblePublication; + private boolean publicationSettlementPending; + private boolean replacementCleanupPending; + private boolean replacementUncertain; + private boolean finalPublicationIndeterminate; /** * Durable state for one locally detected time transition. The identifier is * deliberately stable while a transition is pending so consumers can store @@ -40,8 +48,29 @@ public ServerData(AdvancedCorePlugin plugin) { super(plugin, new File(plugin.getDataFolder(), "ServerData.yml")); } + /** + * The stable monitor protecting this owner's live YAML and whole-file snapshots. + * Callers performing a compound live read/mutate/save must use this monitor, + * rather than the exposed facade. Adapters must return their actual backing + * owner's monitor and use it consistently for their delegated mutations and + * publications. The native owner uses itself. This does not acknowledge queued + * persistence: never wait for a publication stage while holding this monitor. + * + * @return the non-null, stable publication monitor + */ + public Object publicationMonitor() { + return this; + } + + @Override + public synchronized void saveData() { + prepareSnapshotPublication(); + super.saveData(); + } + @Override public synchronized void setup() { + requireFinalPublicationSettled(); Path target = getdFile().toPath().toAbsolutePath(); Path backup = backupPath(target); Path replacementMarker = replacementMarkerPath(target); @@ -53,13 +82,14 @@ public synchronized void setup() { /** Writes transition state as one forced snapshot and reports failures. */ protected synchronized void saveTimeChangeTransitionData() { + prepareSnapshotPublication(); Path target = getdFile().toPath().toAbsolutePath(); Path parent = target.getParent(); Path temporary = null; try { Files.createDirectories(parent); temporary = Files.createTempFile(parent, target.getFileName().toString() + ".", ".tmp"); - getData().save(temporary.toFile()); + (finalDataSnapshot == null ? getData() : finalDataSnapshot).save(temporary.toFile()); try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { channel.force(true); } @@ -79,6 +109,157 @@ protected synchronized void saveTimeChangeTransitionData() { } } + /** Shutdown persistence must report I/O failure rather than acknowledge a failed save. */ + public synchronized void saveDataChecked() { + saveTimeChangeTransitionData(); + } + + /** + * Stages a final YAML mutation on the shutdown persistence caller and confirms + * publication by this file's actual owner. Adapters with another persistence + * owner must override this method and return that owner's publication stage. + * No caller may wait for the stage while holding a YAML owner's monitor. + * Legacy saveData overrides still receive the mutation on their live YAML and + * are invoked once, but their void return cannot prove durable publication and + * therefore produces an exceptional stage. + */ + public java.util.concurrent.CompletionStage mutateFinalData( + java.util.function.Consumer mutation) { + try { + synchronized (this) { + requireFinalPublicationSettled(); + java.util.Objects.requireNonNull(mutation, "mutation"); + Class saveOwner = getClass().getMethod("saveData").getDeclaringClass(); + if (saveOwner != YMLFile.class && saveOwner != ServerData.class) { + // Preserve the legacy mutation-and-save contract without treating + // its void return as confirmation of durable publication. + mutation.accept(getData()); + saveData(); + return java.util.concurrent.CompletableFuture.failedFuture(new UnsupportedOperationException( + "Legacy ServerData owner does not confirm final publication")); + } + var live = getData(); + var candidate = new org.bukkit.configuration.file.YamlConfiguration(); + var before = new java.util.LinkedHashMap(); + for (String path : live.getKeys(false)) { + before.put(path, finalValue(live.get(path))); + setFinalValue(candidate, path, before.get(path)); + } + mutation.accept(candidate); + finalDataSnapshot = candidate; + long previousPublication = visiblePublication; + try { saveDataChecked(); } + catch (Throwable failure) { + // A failed directory force/cleanup does not undo a visible move. + if (visiblePublication != previousPublication) { + try { reconcileFinalData(live, candidate, before); } + catch (Throwable reconciliationFailure) { + replacementUncertain = true; + if (failure != reconciliationFailure) failure.addSuppressed(reconciliationFailure); + } + // Retry only the retained commit's durability/cleanup, never its + // mutation or file replacement. A confirmed commit must settle + // this original stage before a later save can publish its values. + if (!replacementUncertain && (publicationSettlementPending || replacementCleanupPending)) { + try { prepareSnapshotPublication(); } + catch (Throwable settlementFailure) { + finalPublicationIndeterminate = true; + if (failure != settlementFailure) failure.addSuppressed(settlementFailure); + throw failure; + } + } else { + finalPublicationIndeterminate = true; + throw failure; + } + } else { + throw failure; + } + } + finally { finalDataSnapshot = null; } + reconcileFinalData(live, candidate, before); + } + return java.util.concurrent.CompletableFuture.completedFuture(null); + } catch (Throwable failure) { + return java.util.concurrent.CompletableFuture.failedFuture(failure); + } + } + + private void reconcileFinalData(org.bukkit.configuration.file.FileConfiguration live, + org.bukkit.configuration.file.FileConfiguration candidate, java.util.Map before) { + // Retain the live YAML identity and any concurrent direct live updates. + try { + var paths = new java.util.HashSet<>(before.keySet()); + paths.addAll(candidate.getKeys(false)); + for (String path : paths) { + reconcileFinalValue(live, path, before.get(path), finalValue(candidate.get(path))); + } + } catch (RuntimeException | Error failure) { + replacementUncertain = true; + throw failure; + } + } + + private static void reconcileFinalValue(org.bukkit.configuration.ConfigurationSection live, String path, + Object before, Object next) { + if (java.util.Objects.equals(before, next)) return; + Object current = live.get(path); + if ((current instanceof org.bukkit.configuration.ConfigurationSection || current instanceof java.util.Map) + && (before == null || before instanceof java.util.Map) + && (next == null || next instanceof java.util.Map)) { + // Sections are containers, not indivisible CAS values. A change to one + // child must not discard a published change to its independent sibling. + if (current instanceof java.util.Map) setFinalValue(live, path, finalValue(current)); + var section = live.getConfigurationSection(path); + var previousChildren = finalSectionChildren(before); + var nextChildren = finalSectionChildren(next); + var paths = new java.util.HashSet<>(previousChildren.keySet()); + paths.addAll(nextChildren.keySet()); + for (String child : paths) { + reconcileFinalValue(section, child, previousChildren.get(child), nextChildren.get(child)); + } + // Removing a container may remove unchanged children, but must retain + // concurrent child changes/additions rather than delete their parent. + if (next == null && section.getKeys(false).isEmpty()) live.set(path, null); + } else if (java.util.Objects.equals(before, finalValue(current))) { + // Scalars, lists and incompatible type changes still compete at this + // exact field; a newer live value wins over the published candidate. + setFinalValue(live, path, next); + } + } + + private static java.util.Map finalSectionChildren(Object value) { + // Only section child names use string paths. Values remain untouched so + // maps inside lists retain their original key types and distinct entries. + var children = new java.util.LinkedHashMap(); + if (value instanceof java.util.Map map) + map.forEach((key, child) -> children.put(key.toString(), child)); + return children; + } + + private static Object finalValue(Object value) { + if (value instanceof org.bukkit.configuration.ConfigurationSection section) { + var copy = new java.util.LinkedHashMap(); + for (String key : section.getKeys(false)) copy.put(key, finalValue(section.get(key))); + return copy; + } + if (value instanceof java.util.Map map) { + var copy = new java.util.LinkedHashMap(); + map.forEach((key, child) -> copy.put(key, finalValue(child))); + return copy; + } + if (value instanceof java.util.List list) return list.stream().map(ServerData::finalValue).collect( + java.util.stream.Collectors.toCollection(java.util.ArrayList::new)); + return value; + } + + private static void setFinalValue(org.bukkit.configuration.ConfigurationSection target, String path, Object value) { + target.set(path, null); + if (value instanceof java.util.Map map) { + var section = target.createSection(path); + map.forEach((key, child) -> setFinalValue(section, key.toString(), child)); + } else if (value != null) target.set(path, value); + } + protected void moveAtomically(Path source, Path target) throws IOException { Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); } @@ -88,9 +269,11 @@ protected void forceDirectory(Path directory) throws IOException { } private void replaceSnapshot(Path temporary, Path target) throws IOException { + String intendedHash = snapshotHash(temporary); + String previousHash = Files.isRegularFile(target) ? snapshotHash(target) : null; + boolean previouslyAbsent = Files.notExists(target); try { moveAtomically(temporary, target); - forceDirectory(target.getParent()); } catch (AtomicMoveNotSupportedException unsupported) { Path backup = backupPath(target); Path replacementMarker = replacementMarkerPath(target); @@ -101,7 +284,10 @@ private void replaceSnapshot(Path temporary, Path target) throws IOException { } forceDirectory(target.getParent()); } - Files.writeString(replacementMarker, snapshotHash(temporary), StandardOpenOption.CREATE, + // Even a failed marker write can leave recovery state that must be + // removed before a later save changes the target's hash. + replacementCleanupPending = true; + Files.writeString(replacementMarker, intendedHash, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); try (FileChannel channel = FileChannel.open(replacementMarker, StandardOpenOption.WRITE)) { channel.force(true); @@ -109,20 +295,70 @@ private void replaceSnapshot(Path temporary, Path target) throws IOException { forceDirectory(target.getParent()); try { Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException replacementFailure) { - if (Files.exists(backup)) { - Files.copy(backup, target, StandardCopyOption.REPLACE_EXISTING); - DurableFiles.forceFile(target); - forceDirectory(target.getParent()); - } + } catch (IOException | RuntimeException | Error replacementFailure) { + inspectFailedMove(target, intendedHash, previousHash, previouslyAbsent, replacementFailure); throw replacementFailure; } + visiblePublication++; + publicationSettlementPending = true; // Keep the recovery marker until the replacement's directory entry is // durable. If this force fails, startup can identify the committed target // by its recorded hash instead of rolling it back as an incomplete move. forceDirectory(target.getParent()); + publicationSettlementPending = false; clearReplacementState(replacementMarker, backup); + replacementCleanupPending = false; + return; + } catch (IOException | RuntimeException | Error replacementFailure) { + inspectFailedMove(target, intendedHash, previousHash, previouslyAbsent, replacementFailure); + throw replacementFailure; + } + visiblePublication++; + publicationSettlementPending = true; + forceDirectory(target.getParent()); + publicationSettlementPending = false; + } + + private void inspectFailedMove(Path target, String intendedHash, String previousHash, + boolean previouslyAbsent, Throwable failure) { + try { + if (Files.isRegularFile(target)) { + String actualHash = snapshotHash(target); + // Equal predecessor/candidate bytes cannot prove that the forced + // temporary file replaced an older, potentially unforced target. + if (actualHash.equals(previousHash)) return; + if (intendedHash.equals(actualHash)) { + visiblePublication++; + publicationSettlementPending = true; + return; + } + } else if (previouslyAbsent && Files.notExists(target)) return; + } catch (IOException | RuntimeException inspectionFailure) { + if (failure != inspectionFailure) failure.addSuppressed(inspectionFailure); } + // A throwing move may already have changed its target. Never overwrite + // an unknown outcome with stale live YAML or a predecessor backup. + replacementUncertain = true; + } + + private void prepareSnapshotPublication() { + requireFinalPublicationSettled(); + if (!publicationSettlementPending && !replacementCleanupPending) return; + Path target = getdFile().toPath().toAbsolutePath(); + try { forceDirectory(target.getParent()); } + catch (IOException failure) { throw new UncheckedIOException("Failed to settle ServerData replacement", failure); } + publicationSettlementPending = false; + if (replacementCleanupPending) { + clearReplacementState(replacementMarkerPath(target), backupPath(target)); + replacementCleanupPending = false; + } + } + + private void requireFinalPublicationSettled() { + if (finalPublicationIndeterminate) + throw new IllegalStateException("Final ServerData publication is unconfirmed; restart required before reuse"); + if (replacementUncertain) + throw new IllegalStateException("ServerData replacement outcome is unknown; restart required before saving"); } private Path backupPath(Path target) { @@ -178,117 +414,175 @@ private void restoreBackup(Path backup, Path target) { private void clearReplacementState(Path marker, Path backup) { try { - Files.deleteIfExists(backup); + // The target is settled before cleanup. Remove recovery admission + // first; an orphan backup is safe, a marker without its backup is not. + Files.deleteIfExists(marker); forceDirectory(marker.getParent()); - Files.delete(marker); + Files.deleteIfExists(backup); forceDirectory(marker.getParent()); } catch (IOException failure) { throw new UncheckedIOException("Failed to clear interrupted replacement marker", failure); } } - - public long getLastUpdated() { - return getData().getLong("LastUpdated", -1); - } - - /** - * Gets the plugin version. - * - * @param plugin the plugin - * @return the plugin version - */ - public String getPluginVersion(Plugin plugin) { - return getData().getString("PluginVersions." + plugin.getName(), ""); - } - - /** - * Gets the prev day. - * - * @return the prev day - */ - public int getPrevDay() { - return getData().getInt("PrevDay", -1); - } - - /** - * Gets the prev month. - * - * @return the prev month - */ - public String getPrevMonth() { - return getData().getString("Month", ""); - } - - /** - * Gets the prev week day. - * - * @return the prev week day - */ - public int getPrevWeekDay() { - return getData().getInt("PrevWeek", -1); - } - - public boolean isIgnoreTime() { - return getData().getBoolean("IgnoreTime", false); - } - - @Override - public void onFileCreation() { - } - - public void setData(String path, Object value) { - getData().set(path, value); - saveData(); - } - - public void setIgnoreTime(boolean value) { - getData().set("IgnoreTime", value); - saveData(); - } - - public void setLastUpdated() { - getData().set("LastUpdated", System.currentTimeMillis()); - saveData(); - } - - /** - * Sets the plugin version. - * - * @param plugin the new plugin version - */ - public void setPluginVersion(Plugin plugin) { - getData().set("PluginVersions." + plugin.getName(), plugin.getDescription().getVersion()); - saveData(); - } - - /** - * Sets the prev day. - * - * @param day the new prev day - */ - public void setPrevDay(int day) { - getData().set("PrevDay", day); - saveData(); - } - - /** - * Sets the prev month. - * - * @param month the new prev month - */ - public void setPrevMonth(String month) { - getData().set("Month", month); - saveData(); - } - - /** - * Sets the prev week day. - * - * @param week the new prev week day - */ + + public long getLastUpdated() { + return getData().getLong("LastUpdated", -1); + } + + /** + * Gets the plugin version. + * + * @param plugin the plugin + * @return the plugin version + */ + public String getPluginVersion(Plugin plugin) { + return getData().getString("PluginVersions." + plugin.getName(), ""); + } + + /** + * Gets the prev day. + * + * @return the prev day + */ + public int getPrevDay() { + return getData().getInt("PrevDay", -1); + } + + /** + * Gets the prev month. + * + * @return the prev month + */ + public String getPrevMonth() { + return getData().getString("Month", ""); + } + + /** + * Gets the prev week day. + * + * @return the prev week day + */ + public int getPrevWeekDay() { + return getData().getInt("PrevWeek", -1); + } + + public boolean isIgnoreTime() { + return getData().getBoolean("IgnoreTime", false); + } + + @Override + public void onFileCreation() { + } + + private void withNativePublicationOrdering(Runnable operation) { + try { + Class owner = getClass().getMethod("saveData").getDeclaringClass(); + if (owner == ServerData.class || owner == YMLFile.class) { + synchronized (this) { requireFinalPublicationSettled(); operation.run(); } + } else { + // Existing adapters retain their own nonblocking mutation/publication model. + operation.run(); + } + } catch (NoSuchMethodException impossible) { + throw new IllegalStateException("ServerData publisher unavailable", impossible); + } + } + + /** + * Mutates the live YAML and then invokes the ordinary {@link #saveData()} path. + * The callback runs synchronously on the caller before saving, so an adapter + * can update its in-memory projection before disk I/O begins. Native owners + * check final-publication settlement and hold their publication monitor across + * both operations; the callback receives the existing live YAML, not a copy. + * Adapters with another owner must delegate this operation to that owner and + * compose their projection update inside its callback. + * + * This is an ordinary save, not a durable publication acknowledgement. A callback + * failure propagates without saving or rolling back any live changes. Callbacks + * must not wait for asynchronous work or perform another save/reload operation. + * Existing custom save owners retain their own synchronization and save behavior. + * + * @param mutation the synchronous mutation and optional in-memory projection update + */ + public void mutateOrdinaryData(java.util.function.Consumer mutation) { + withNativePublicationOrdering(() -> { mutation.accept(getData()); saveData(); }); + } + + /** Reload must not replace the live YAML identity during checked native publication. */ + @Override + public void reloadData() { + withNativePublicationOrdering(super::reloadData); + } + + /** Bulk updates share native publication ordering and retain their no-save semantics. */ + @Override + public void setData(org.bukkit.configuration.file.FileConfiguration data) { + withNativePublicationOrdering(() -> super.setData(data)); + } + + /** + * Keeps the inherited SimpleAPI mutators inside the native publication + * monitor. Their default implementations mutate the live configuration + * before entering {@link #saveData()}, which allows a final publication to + * observe and later overwrite an uncommitted ordinary change. + */ + @Override + public void setValue(String path, Object value) { + mutateOrdinaryData(data -> data.set(path, value)); + } + + /** See {@link #setValue(String, Object)} for the publication ordering rule. */ + @Override + public void createSection(String path) { + mutateOrdinaryData(data -> data.createSection(path)); + } + + public void setData(String path, Object value) { + mutateOrdinaryData(data -> data.set(path, value)); + } + + public void setIgnoreTime(boolean value) { + mutateOrdinaryData(data -> data.set("IgnoreTime", value)); + } + + public void setLastUpdated() { + mutateOrdinaryData(data -> data.set("LastUpdated", System.currentTimeMillis())); + } + + /** + * Sets the plugin version. + * + * @param plugin the new plugin version + */ + public void setPluginVersion(Plugin plugin) { + mutateOrdinaryData(data -> data.set("PluginVersions." + plugin.getName(), plugin.getDescription().getVersion())); + } + + /** + * Sets the prev day. + * + * @param day the new prev day + */ + public void setPrevDay(int day) { + mutateOrdinaryData(data -> data.set("PrevDay", day)); + } + + /** + * Sets the prev month. + * + * @param month the new prev month + */ + public void setPrevMonth(String month) { + mutateOrdinaryData(data -> data.set("Month", month)); + } + + /** + * Sets the prev week day. + * + * @param week the new prev week day + */ public void setPrevWeekDay(int week) { - getData().set("PrevWeek", week); - saveData(); + mutateOrdinaryData(data -> data.set("PrevWeek", week)); } /** @@ -302,6 +596,7 @@ public synchronized TimeChangeTransitionState beginTimeChangeTransition(TimeType String existingId = getData().getString(path + ".Id", ""); boolean pending = getData().getBoolean(path + ".Pending", false); if (pending && periodKey.equals(existingPeriod) && !existingId.isEmpty()) { + prepareSnapshotPublication(); return new TimeChangeTransitionState(type, existingId, existingPeriod, getData().getString(path + ".Marker", markerValue), true); } @@ -311,6 +606,7 @@ public synchronized TimeChangeTransitionState beginTimeChangeTransition(TimeType Object previousPeriod = getData().get(path + ".Period"); Object previousMarker = getData().get(path + ".Marker"); Object previousPending = getData().get(path + ".Pending"); + long previousPublication = visiblePublication; try { getData().set(path + ".Id", id); getData().set(path + ".Period", periodKey); @@ -318,19 +614,27 @@ public synchronized TimeChangeTransitionState beginTimeChangeTransition(TimeType getData().set(path + ".Pending", true); saveTimeChangeTransitionData(); } catch (RuntimeException | Error failure) { - getData().set(path + ".Id", previousId); - getData().set(path + ".Period", previousPeriod); - getData().set(path + ".Marker", previousMarker); - getData().set(path + ".Pending", previousPending); + if (visiblePublication == previousPublication) { + getData().set(path + ".Id", previousId); + getData().set(path + ".Period", previousPeriod); + getData().set(path + ".Marker", previousMarker); + getData().set(path + ".Pending", previousPending); + } throw failure; } return new TimeChangeTransitionState(type, id, periodKey, markerValue, true); } - /** Returns an unfinished transition even when the current clock has moved on. */ + /** + * Returns an unfinished transition even when the current clock has moved on. + * This observes known visible state without confirming durable publication; + * checked transition admission must settle it before event dispatch. + */ public synchronized TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) { String path = transitionPath(type); if (!getData().getBoolean(path + ".Pending", false)) return null; + if (replacementUncertain) + throw new IllegalStateException("ServerData replacement outcome is unknown; restart required before processing a pending transition"); String id = getData().getString(path + ".Id", ""); String period = getData().getString(path + ".Period", ""); String marker = getData().getString(path + ".Marker", ""); @@ -376,6 +680,7 @@ public synchronized void completeTimeChangeTransition(TimeChangeTransitionState }; Object previousMarker = getData().get(markerPath); String pendingPath = transitionPath(transition.type()) + ".Pending"; + long previousPublication = visiblePublication; try { switch (transition.type()) { case DAY, WEEK -> getData().set(markerPath, Integer.parseInt(transition.markerValue())); @@ -384,8 +689,10 @@ public synchronized void completeTimeChangeTransition(TimeChangeTransitionState getData().set(pendingPath, false); saveTimeChangeTransitionData(); } catch (RuntimeException | Error failure) { - getData().set(markerPath, previousMarker); - getData().set(pendingPath, true); + if (visiblePublication == previousPublication) { + getData().set(markerPath, previousMarker); + getData().set(pendingPath, true); + } throw failure; } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/AdvancedCoreLifecycle.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/AdvancedCoreLifecycle.java index fd113b27cd..82a31d47cd 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/AdvancedCoreLifecycle.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/AdvancedCoreLifecycle.java @@ -27,7 +27,11 @@ public static RuntimeExecutors createRuntimeExecutors(AdvancedCorePlugin plugin) } public void shutdown() { - if (plugin != null) new AdvancedCoreRuntime(new BukkitRuntimePlatform(plugin)).shutdown(); + shutdown(System.nanoTime()); + } + + public void shutdown(long startedNanos) { + if (plugin != null) new AdvancedCoreRuntime(new BukkitRuntimePlatform(plugin)).shutdown(startedNanos); } static void shutdown(ScheduledExecutorService executor) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/ServerDataOwnership.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/ServerDataOwnership.java new file mode 100644 index 0000000000..6ea7ed4ca9 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/lifecycle/ServerDataOwnership.java @@ -0,0 +1,143 @@ +package com.bencodez.advancedcore.lifecycle; + +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.Objects; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Non-blocking admission before native ServerData reads. Only opaque String + * tokens live in JVM properties, so shaded plugin classloaders share admission + * without retaining plugin objects, futures, callbacks, or classloaders there. + */ +public final class ServerDataOwnership { + private static final String PREFIX = "AdvancedCore:ServerData-ownership:v1:"; + private static final int MAX_OWNERS = 256; + private final Properties properties; + private final String key; + private final String token = UUID.randomUUID().toString(); + private boolean completionAttached; + private boolean released; + private final CompletableFuture shutdownStarted = new CompletableFuture<>(); + private final CompletableFuture processReleased = new CompletableFuture<>(); + private final AtomicBoolean hookRemoved = new AtomicBoolean(); + private Thread processHook; + private Runtime processRuntime; + + private ServerDataOwnership(Properties properties, String key) { + this.properties = properties; + this.key = key; + } + + /** Uses the stable platform data path; no filesystem access or predecessor wait. */ + public static ServerDataOwnership acquire(Path file) { + String path = Objects.requireNonNull(file, "file").toAbsolutePath().normalize().toString(); + if (path.length() > 8192) throw new IllegalArgumentException("ServerData path is too long"); + String key; + try { + key = PREFIX + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(path.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException unavailable) { throw new IllegalStateException(unavailable); } + Properties properties = System.getProperties(); + ServerDataOwnership ownership = new ServerDataOwnership(properties, key); + synchronized (properties) { + if (properties.containsKey(key)) throw new IllegalStateException( + "Predecessor ServerData owner has not finished; startup rejected until it settles or the JVM restarts"); + int active = 0; + for (Object candidate : properties.keySet()) { + if (candidate instanceof String name && name.startsWith(PREFIX) && ++active >= MAX_OWNERS) + throw new IllegalStateException("ServerData ownership capacity reached"); + } + properties.setProperty(key, ownership.token); + } + try { ownership.installProcessHook(); } + catch (RuntimeException | Error failure) { + synchronized (properties) { + if (ownership.token.equals(properties.get(key))) properties.remove(key); + } + throw failure; + } + return ownership; + } + + private void installProcessHook() { + // Register during startup: registering from a host's shutdown hook is too late. + // The hook observes ownership only; it never invokes plugin or Bukkit callbacks. + processRuntime = Runtime.getRuntime(); + processHook = new Thread(this::awaitProcessExit, "AdvancedCore-ServerData-Exit"); + processRuntime.addShutdownHook(processHook); + } + + /** Starts once at disable entry; final admission cannot renew the same deadline. */ + public void beginShutdown(long startedNanos) { + if (shutdownStarted.complete(startedNanos)) { + CompletableFuture.delayedExecutor(remaining(startedNanos), TimeUnit.NANOSECONDS) + .execute(this::removeProcessHook); + } + } + + private void awaitProcessExit() { + long processExitStarted = System.nanoTime(); + try { + long disableStarted = shutdownStarted.get(remaining(processExitStarted), TimeUnit.NANOSECONDS); + long budget = Math.min(remaining(processExitStarted), remaining(disableStarted)); + if (budget > 0) processReleased.get(budget, TimeUnit.NANOSECONDS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } catch (ExecutionException | TimeoutException settledOrExpired) { + // Publication stages retain their real outcome; timeout is never an acknowledgement. + } + } + + private static long remaining(long startedNanos) { + long elapsed = Math.max(0L, System.nanoTime() - startedNanos); + return Math.max(0L, TimeUnit.SECONDS.toNanos(15) - elapsed); + } + + private void removeProcessHook() { + if (!hookRemoved.compareAndSet(false, true)) return; + try { processRuntime.removeShutdownHook(processHook); } + catch (IllegalStateException stopping) { /* The JVM is already invoking its hooks. */ } + } + + /** + * Release once after successful preparation and publication settle. A failed + * or cancelled stage can precede another admitted writer's termination, so + * it deliberately retains admission until restart rather than guessing drain. + * Cancelling a caller's observation must not cancel this completion stage. + */ + public void releaseAfter(CompletionStage completion) { + Objects.requireNonNull(completion, "completion"); + synchronized (this) { + if (completionAttached) return; + completionAttached = true; + } + beginShutdown(System.nanoTime()); + completion.whenComplete((ignored, failure) -> { + if (failure != null) return; + synchronized (this) { + if (released) return; + synchronized (properties) { + if (!token.equals(properties.get(key))) return; + properties.remove(key); + released = true; + } + } + processReleased.complete(null); + removeProcessHook(); + }); + } + + public synchronized boolean isReleased() { return released; } + +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/permissions/PermissionShutdownPersistenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/permissions/PermissionShutdownPersistenceTest.java new file mode 100644 index 0000000000..8d329544ab --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/permissions/PermissionShutdownPersistenceTest.java @@ -0,0 +1,97 @@ +package com.bencodez.advancedcore.tests.api.permissions; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.permissions.PermissionAttachment; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.permissions.PermissionHandler; +import com.bencodez.advancedcore.api.permissions.PlayerPermissionHandler; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.data.ServerData; +import com.bencodez.simpleapi.time.ParsedDuration; + +class PermissionShutdownPersistenceTest { + @Test void shutdownFencesExpirationAndReturnsBeforeBlockedCheckedSave() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ServerData serverData = mock(ServerData.class); + doCallRealMethod().when(serverData).mutateFinalData(any()); + YamlConfiguration data = new YamlConfiguration(); + when(plugin.getServerDataFile()).thenReturn(serverData); + when(serverData.getData()).thenReturn(data); + PermissionHandler manager = new PermissionHandler(plugin); + UUID uuid = UUID.randomUUID(); + PermissionAttachment attachment = mock(PermissionAttachment.class); + PlayerPermissionHandler handle = new PlayerPermissionHandler(uuid, attachment, manager) + .addExpiration("example.use", ParsedDuration.ofMillis(60_000)); + manager.getPerms().put(uuid, handle); + long expiry = handle.getTimedPermissions().get("example.use"); + clearInvocations(attachment); + CountDownLatch saving = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference persistenceThread = new AtomicReference<>(); + doAnswer(call -> { + persistenceThread.set(Thread.currentThread()); saving.countDown(); + assertTrue(release.await(2, TimeUnit.SECONDS)); return null; + }).when(serverData).saveDataChecked(); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor( + AdvancedCoreRuntime.daemonThreads("permission-save-fixture")); + try { + assertTrue(manager.getTimer().submit(() -> Thread.currentThread().isDaemon()).get(2, TimeUnit.SECONDS)); + var completion = manager.shutDownAsync(worker); + assertTrue(saving.await(2, TimeUnit.SECONDS)); + assertFalse(completion.toCompletableFuture().isDone()); + assertTrue(manager.getTimer().isShutdown()); + assertTrue(persistenceThread.get().isDaemon()); + assertNotSame(Thread.currentThread(), persistenceThread.get()); + assertSame(completion, manager.shutDownAsync(worker)); + PermissionAccess.dispatchExpiration(manager, handle, "example.use", expiry); + verifyNoInteractions(attachment); + assertFalse(data.contains("TimedPermissions." + uuid), "pending publication cannot change live YAML"); + release.countDown(); + completion.toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(java.util.List.of("example.use%line%" + expiry), data.getStringList("TimedPermissions." + uuid)); + verify(serverData, times(1)).saveDataChecked(); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + + @Test void failedSaveRemainsExceptionalAndNeverCallsLegacySave() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ServerData serverData = mock(ServerData.class); + doCallRealMethod().when(serverData).mutateFinalData(any()); + when(plugin.getServerDataFile()).thenReturn(serverData); + when(serverData.getData()).thenReturn(new YamlConfiguration()); + PermissionHandler manager = new PermissionHandler(plugin); + var failure = new java.io.UncheckedIOException(new java.io.IOException("disk failure")); + doThrow(failure).when(serverData).saveDataChecked(); + var completion = manager.shutDownAsync(Runnable::run); + assertSame(failure, assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + verify(serverData, never()).saveData(); + assertTrue(manager.getTimer().isShutdown()); + } + + @Test void rejectionDoesNotFallBackToCallerThreadPersistence() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ServerData serverData = mock(ServerData.class); + doCallRealMethod().when(serverData).mutateFinalData(any()); + when(plugin.getServerDataFile()).thenReturn(serverData); + when(serverData.getData()).thenReturn(new YamlConfiguration()); + PermissionHandler manager = new PermissionHandler(plugin); + var completion = manager.shutDownAsync(task -> { + throw new java.util.concurrent.RejectedExecutionException("stopped"); + }); + assertInstanceOf(java.util.concurrent.RejectedExecutionException.class, + assertThrows(CompletionException.class, completion.toCompletableFuture()::join).getCause()); + verify(serverData, never()).saveDataChecked(); + assertTrue(manager.getTimer().isShutdown()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java index 34768eedcb..ca1cac6bb5 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/rewards/RewardGamemodeMutationTest.java @@ -132,6 +132,7 @@ private void mutationScenario(boolean itemAction, String rule, boolean changeMod when(plugin.getTimer()).thenReturn(timer); doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }).when(timer).execute(any()); ServerData serverData = mock(ServerData.class); + when(serverData.publicationMonitor()).thenCallRealMethod(); when(plugin.getServerDataFile()).thenReturn(serverData); when(serverData.getData()).thenReturn(new YamlConfiguration()); FullInventoryHandler inventoryHandler = itemAction ? new FullInventoryHandler(plugin) : null; diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRecurringTaskTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRecurringTaskTest.java index 4a6c2fa64f..4212dec048 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRecurringTaskTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRecurringTaskTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; import java.util.concurrent.TimeUnit; import java.util.logging.Logger; @@ -45,7 +46,7 @@ void transientFailureDoesNotPreventTheNextRecurringAttempt() { Mockito.doThrow(new IllegalStateException("disk unavailable")).doNothing().when(checker).update(); try (MockedStatic executors = Mockito.mockStatic(Executors.class)) { - executors.when(Executors::newSingleThreadScheduledExecutor).thenReturn(timer); + executors.when(() -> Executors.newSingleThreadScheduledExecutor(any(ThreadFactory.class))).thenReturn(timer); checker.loadTimer(); } recurring.getValue().run(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRequestedUpdateTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRequestedUpdateTest.java new file mode 100644 index 0000000000..0d17da6b2b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/api/time/TimeCheckerRequestedUpdateTest.java @@ -0,0 +1,114 @@ +package com.bencodez.advancedcore.tests.api.time; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.time.TimeChecker; + +class TimeCheckerRequestedUpdateTest { + private AdvancedCorePlugin plugin() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + return plugin; + } + @Test void repeatedRequestsCoalesceAndNeverRunInline() { + var plugin = plugin(); var checker = spy(new TimeChecker(plugin)); + var timer = mock(ScheduledExecutorService.class); Queue tasks = new ArrayDeque<>(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + checker.setTimer(timer); doNothing().when(checker).update(); + for (int i = 0; i < 100; i++) checker.requestUpdate(); + assertEquals(1, tasks.size()); verify(checker, never()).update(); + tasks.remove().run(); verify(checker).update(); + checker.requestUpdate(); assertEquals(1, tasks.size()); + tasks.remove().run(); verify(checker, times(2)).update(); + } + @Test void admittedButNotStartedCheckIsSkippedAndDrainedOnShutdown() { + var checker = spy(new TimeChecker(plugin())); var timer = mock(ScheduledExecutorService.class); + AtomicReference task = new AtomicReference<>(); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + checker.setTimer(timer); checker.requestUpdate(); + var stopped = checker.beginShutdown().toCompletableFuture(); assertFalse(stopped.isDone()); + task.get().run(); assertTrue(stopped.isDone()); verify(checker, never()).update(); + checker.requestUpdate(); verify(timer).execute(any(Runnable.class)); + } + @Test void deferredCleanupSettlesQueuedRequestBeforeExecutorIsForcedDown() { + var checker = spy(new TimeChecker(plugin())); var timer = mock(ScheduledExecutorService.class); + AtomicReference task = new AtomicReference<>(); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + checker.setTimer(timer); checker.requestUpdate(); + var stopped = checker.beginShutdown().toCompletableFuture(); + assertFalse(stopped.isDone()); + checker.cancelQueuedUpdate(); + assertTrue(stopped.isDone()); + task.get().run(); + verify(checker, never()).update(); + } + @Test void deferredCleanupLeavesRunningRequestOwnedUntilItFinishes() throws Exception { + var plugin = plugin(); var checker = spy(new TimeChecker(plugin)); + var timer = Executors.newSingleThreadScheduledExecutor(); checker.setTimer(timer); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; }).when(checker).update(); + try { + checker.requestUpdate(); assertTrue(entered.await(5, TimeUnit.SECONDS)); + var stopped = checker.beginShutdown().toCompletableFuture(); + checker.cancelQueuedUpdate(); + assertFalse(stopped.isDone()); + release.countDown(); + stopped.get(5, TimeUnit.SECONDS); + } finally { release.countDown(); timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); } + } + @Test void rejectedSubmissionReleasesAdmissionForRetry() { + var checker = spy(new TimeChecker(plugin())); var timer = mock(ScheduledExecutorService.class); + doThrow(new RejectedExecutionException()).doNothing().when(timer).execute(any(Runnable.class)); + checker.setTimer(timer); checker.requestUpdate(); checker.requestUpdate(); + verify(timer, times(2)).execute(any(Runnable.class)); verify(checker, never()).update(); + } + @Test void retiredTimerAndDisabledPluginCannotExecuteChecks() { + for (boolean disabled : new boolean[] { false, true }) { + var plugin = plugin(); var checker = spy(new TimeChecker(plugin)); + var timer = mock(ScheduledExecutorService.class); AtomicReference task = new AtomicReference<>(); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + checker.setTimer(timer); checker.requestUpdate(); + if (disabled) when(plugin.isEnabled()).thenReturn(false); else checker.setTimer(mock(ScheduledExecutorService.class)); + task.get().run(); verify(checker, never()).update(); + assertTrue(checker.beginShutdown().toCompletableFuture().isDone()); + } + } + @Test void backgroundExecutionRemainsOwnedUntilRunningCheckReturns() throws Exception { + var plugin = plugin(); var checker = spy(new TimeChecker(plugin)); + var timer = Executors.newSingleThreadScheduledExecutor(); checker.setTimer(timer); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + Thread caller = Thread.currentThread(); AtomicReference worker = new AtomicReference<>(); + doAnswer(call -> { worker.set(Thread.currentThread()); entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); return null; }).when(checker).update(); + try { + checker.requestUpdate(); assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertNotSame(caller, worker.get()); + var stopped = checker.beginShutdown().toCompletableFuture(); assertFalse(stopped.isDone()); + release.countDown(); stopped.get(5, TimeUnit.SECONDS); + verify(checker).update(); + } finally { release.countDown(); timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); } + } + @Test void failureDoesNotStrandShutdownOrFutureRequests() { + var checker = spy(new TimeChecker(plugin())); var timer = mock(ScheduledExecutorService.class); + Queue tasks = new ArrayDeque<>(); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + checker.setTimer(timer); doThrow(new IllegalStateException("fixture failure")).doNothing().when(checker).update(); + checker.requestUpdate(); tasks.remove().run(); checker.requestUpdate(); tasks.remove().run(); + verify(checker, times(2)).update(); assertTrue(checker.beginShutdown().toCompletableFuture().isDone()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryFinalPublicationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryFinalPublicationTest.java new file mode 100644 index 0000000000..37932c57f1 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryFinalPublicationTest.java @@ -0,0 +1,200 @@ +package com.bencodez.advancedcore.tests.item; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +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.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.bukkit.configuration.serialization.SerializableAs; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.data.ServerData; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class FullInventoryFinalPublicationTest { + @TempDir Path directory; + + @Test + void failedFinalPublicationCannotLeakOverflowThroughLaterSaveAndRestart() throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(true); + } + + @Test + void successfulFinalPublicationKeepsOverflowAndReceiptConsistentAfterRestart() throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(false); + } + + @Test void transientDirectoryFailureSettlesOriginalInventoryReceiptBeforeLaterSaves() throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(false, true, false); + } + + @Test void fallbackDirectoryFailureSettlesOriginalInventoryReceiptBeforeLaterSaves() throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(false, true, true); + } + + @Test void fallbackMarkerCleanupFailureSettlesOriginalInventoryReceiptBeforeLaterSaves() throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(false, true, true, 2); + } + + @Test void fallbackBackupCleanupFailureSettlesOriginalInventoryReceiptBeforeLaterSaves() throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(false, true, true, 3); + } + + private void laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(boolean failInventory) throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(failInventory, false, false); + } + + private void laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(boolean failInventory, + boolean failVisibleDirectory, boolean fallback) throws Exception { + laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(failInventory, failVisibleDirectory, fallback, 1); + } + + private void laterWholeFileSaveAndRestartKeepOverflowAndReplayReceiptConsistent(boolean failInventory, + boolean failVisibleDirectory, boolean fallback, int failingVisibleForce) throws Exception { + ConfigurationSerialization.registerClass(PersistedItem.class, "ACFinalOverflowTestItem"); + var plugin = mock(AdvancedCorePlugin.class); + var options = mock(AdvancedCoreConfigOptions.class); + var scheduler = mock(BukkitScheduler.class); + var server = mock(Server.class); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(mock(org.bukkit.scheduler.BukkitScheduler.class)); + when(plugin.getLogger()).thenReturn(Logger.getLogger("FullInventoryFinalPublicationTest")); + when(plugin.getOptions()).thenReturn(options); when(options.isOnlineMode()).thenReturn(true); + when(plugin.isEnabled()).thenReturn(true); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + var checkpointExecutor = mock(java.util.concurrent.ScheduledExecutorService.class); + doAnswer(call -> { call.getArgument(0, Runnable.class).run(); return null; }) + .when(checkpointExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(checkpointExecutor); + var owner = new FailingPublicationOwner(plugin); owner.setup(); + owner.fallback = fallback; + owner.failingVisibleForce = failingVisibleForce; + when(plugin.getServerDataFile()).thenReturn(owner); + var handler = new FullInventoryHandler(plugin); + when(plugin.getFullInventoryHandler()).thenReturn(handler); + var uuid = UUID.randomUUID(); var player = mock(Player.class); var inventory = mock(PlayerInventory.class); + when(player.getUniqueId()).thenReturn(uuid); when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + var item = new PersistedItem(); + when(inventory.addItem(item)).thenReturn(new HashMap<>(Map.of(0, item)), new HashMap<>()); + handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + var ownerTask = new AtomicReference(); + doAnswer(call -> { ownerTask.set(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + var timerEntered = new CountDownLatch(1); var releaseTimer = new CountDownLatch(1); + handler.getTimer().execute(() -> { + timerEntered.countDown(); + try { releaseTimer.await(5, TimeUnit.SECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(timerEntered.await(2, TimeUnit.SECONDS)); + var durableCheckpoint = new AtomicReference<>(new HashMap()); + var rewardOptions = new RewardOptions(); + rewardOptions.setAsyncReplayCheckpointConsumer( + checkpoint -> durableCheckpoint.set(new HashMap<>(checkpoint.getPlaceholders()))); + String injection = "AsyncReward/FinalOverflow"; + FullInventoryHandler restarted = null; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + var user = new AdvancedCoreUser(plugin, uuid, false, false); + var placeholders = new HashMap(); + var actions = user.beginAsyncActionCollection(Reward.replayStateFor(rewardOptions), placeholders, injection); + user.giveItem(item); + var replay = user.endAsyncActionCollection(actions).toCompletableFuture(); + ownerTask.get().run(); assertFalse(replay.isDone()); + owner.failNext.set(failInventory); + owner.failVisibleDirectory.set(failVisibleDirectory); + var finalFiles = handler.shutdownAsync(Runnable::run).toCompletableFuture(); + if (failInventory) assertThrows(java.util.concurrent.CompletionException.class, finalFiles::join); + else finalFiles.get(2, TimeUnit.SECONDS); + // These are the actual subsequent whole-file final mutations, not an + // acknowledgement inferred from the failed inventory publication. + owner.mutateFinalData(yaml -> yaml.set("LastUpdated", 2468L)).toCompletableFuture().join(); + owner.mutateFinalData(yaml -> yaml.set("TimedPermissions.example", java.util.List.of("example.use%line%12345"))) + .toCompletableFuture().join(); + var disk = YamlConfiguration.loadConfiguration(owner.getdFile()); + assertEquals(!failInventory, disk.contains("FullInventory." + uuid)); + assertEquals(!failInventory, replay.isDone()); + assertEquals(!failInventory, durableCheckpoint.get().containsKey(Reward.legacyActionReplayKey(injection))); + assertEquals(2468L, disk.getLong("LastUpdated")); + assertFalse(failInventory && handler.getItems().containsKey(uuid)); + // A successor independently reads the published file and the checkpoint + // emitted by the production action collection, as restart recovery does. + var successor = new FailingPublicationOwner(plugin); successor.setup(); + when(plugin.getServerDataFile()).thenReturn(successor); + restarted = new FullInventoryHandler(plugin); when(plugin.getFullInventoryHandler()).thenReturn(restarted); + restarted.startup(); + assertEquals(!failInventory, restarted.getItems().containsKey(uuid)); + ownerTask.set(null); + var successorUser = new AdvancedCoreUser(plugin, uuid, false, false); + var recovered = new HashMap<>(durableCheckpoint.get()); + var resumed = successorUser.beginAsyncActionCollection(Reward.replayStateFor(rewardOptions), recovered, injection); + successorUser.giveItem(item); + var retried = successorUser.endAsyncActionCollection(resumed).toCompletableFuture(); + if (failInventory) { assertNotNull(ownerTask.get()); ownerTask.get().run(); } + else assertNull(ownerTask.get(), "completed action receipt must suppress replay"); + retried.get(2, TimeUnit.SECONDS); + verify(inventory, times(failInventory ? 2 : 1)).addItem(item); + } finally { + releaseTimer.countDown(); handler.getTimer().shutdownNow(); + if (restarted != null) restarted.getTimer().shutdownNow(); + ConfigurationSerialization.unregisterClass(PersistedItem.class); + } + } + + private static final class FailingPublicationOwner extends ServerData { + private final AtomicBoolean failNext = new AtomicBoolean(); + private final AtomicBoolean failVisibleDirectory = new AtomicBoolean(); + private boolean fallback; + private int failingVisibleForce = 1; + private int visibleForces; + private FailingPublicationOwner(AdvancedCorePlugin plugin) { super(plugin); } + @Override protected void moveAtomically(Path source, Path target) throws IOException { + if (failNext.compareAndSet(true, false)) throw new IOException("inventory publication rejected"); + if (fallback) throw new java.nio.file.AtomicMoveNotSupportedException(source.toString(), target.toString(), "fixture"); + super.moveAtomically(source, target); + } + @Override protected void forceDirectory(Path directory) throws IOException { + if (java.nio.file.Files.exists(getdFile().toPath()) + && java.nio.file.Files.readString(getdFile().toPath()).contains("FullInventory:") + && failVisibleDirectory.get() && ++visibleForces == failingVisibleForce + && failVisibleDirectory.compareAndSet(true, false)) + throw new IOException("synthetic transient directory force failure after visible inventory"); + super.forceDirectory(directory); + } + } + + /** A headless item serializer; delivery still uses the production inventory path. */ + @SerializableAs("ACFinalOverflowTestItem") + public static final class PersistedItem extends ItemStack { + public PersistedItem() { super(Material.STONE, 1); } + @Override public Map serialize() { return Map.of("amount", 1); } + public static PersistedItem deserialize(Map value) { return new PersistedItem(); } + @Override public boolean equals(Object value) { return value instanceof PersistedItem; } + @Override public int hashCode() { return 1; } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java index c50bc4840c..7fd6915a6c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryHandlerTest.java @@ -415,7 +415,7 @@ public void replayDropFailurePersistsOnlyTheUndroppedOverflow() throws Exception } @Test - public void replayOverflowSaveFailureDropsOnlyReservedOverflowWithoutReplayingPartialInsert() throws Exception { + public void replayOverflowRejectedBeforePublicationDropsOnlyReservedOverflowWithoutReplayingPartialInsert() throws Exception { Fixture fixture = createFixture(); UUID uuid = UUID.randomUUID(); Player player = mock(Player.class); @@ -433,9 +433,7 @@ public void replayOverflowSaveFailureDropsOnlyReservedOverflowWithoutReplayingPa when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); when(world.dropItem(location, excess)).thenReturn(mock(org.bukkit.entity.Item.class)); fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); - doAnswer(invocation -> { - throw new IllegalStateException("disk unavailable"); - }).when(fixture.serverData).saveData(); + fixture.handler.getTimer().shutdownNow(); // Rejected before any owner publication. AtomicInteger scheduled = new AtomicInteger(); AtomicReference initialDelivery = new AtomicReference<>(); @@ -487,9 +485,7 @@ public void replayOverflowDoesNotAcknowledgeAnUnavailableFallback() throws Excep when(player.getInventory()).thenReturn(inventory); when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); - doAnswer(invocation -> { - throw new IllegalStateException("disk unavailable"); - }).when(fixture.serverData).saveData(); + fixture.handler.getTimer().shutdownNow(); // Rejected before any owner publication. AtomicReference initialDelivery = new AtomicReference<>(); AtomicReference fallback = new AtomicReference<>(); @@ -519,6 +515,140 @@ public void replayOverflowDoesNotAcknowledgeAnUnavailableFallback() throws Excep assertFalse(fixture.data.contains("FullInventory")); } + @Test + public void asyncShutdownReturnsWhileCheckedSnapshotIsBlockedOnOwnedDaemonWorker() throws Exception { + Fixture fixture = createFixture(); + assertTrue(fixture.handler.getTimer().submit(() -> Thread.currentThread().isDaemon()).get(2, TimeUnit.SECONDS)); + UUID uuid = UUID.randomUUID(); + ItemStack item = mock(ItemStack.class); + fixture.handler.add(uuid, item); + CountDownLatch saving = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicReference workerThread = new AtomicReference<>(); + doAnswer(call -> { + workerThread.set(Thread.currentThread()); + saving.countDown(); + assertTrue(release.await(2, TimeUnit.SECONDS)); + return null; + }).when(fixture.serverData).saveDataChecked(); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor( + com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime.daemonThreads("inventory-save-fixture")); + try { + CompletionStage completion = fixture.handler.shutdownAsync(worker); + assertTrue(saving.await(2, TimeUnit.SECONDS)); + assertFalse(completion.toCompletableFuture().isDone()); + assertTrue(workerThread.get().isDaemon()); + assertNotSame(Thread.currentThread(), workerThread.get()); + assertEquals(completion, fixture.handler.shutdownAsync(worker)); + release.countDown(); + completion.toCompletableFuture().get(2, TimeUnit.SECONDS); + assertEquals(item, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + verify(fixture.serverData, times(1)).saveDataChecked(); + } finally { + release.countDown(); + worker.shutdownNow(); + } + } + + @Test + public void failedCheckedShutdownSaveDoesNotAcknowledgeAcceptedReplayOverflow() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = mock(ItemStack.class); + ItemStack excess = mock(ItemStack.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + fixture.handler.getTimer().execute(() -> { + started.countDown(); + try { release.await(2, TimeUnit.SECONDS); } + catch (InterruptedException ignored) { Thread.currentThread().interrupt(); } + }); + assertTrue(started.await(2, TimeUnit.SECONDS)); + try { + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.bukkitScheduler).runTask(eq(fixture.plugin), task.capture(), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + task.getValue().run(); + } + doAnswer(call -> { throw new java.io.UncheckedIOException(new java.io.IOException("disk failure")); }) + .when(fixture.serverData).saveDataChecked(); + CompletionStage completion = fixture.handler.shutdownAsync(Runnable::run); + assertThrows(java.util.concurrent.CompletionException.class, () -> completion.toCompletableFuture().join()); + assertFalse(delivery.toCompletableFuture().isDone(), "failed disk save must keep the replay receipt pending"); + assertFalse(fixture.handler.getItems().containsKey(uuid), "failed reservation must not enter the delivery sweep"); + assertFalse(fixture.data.contains("FullInventory." + uuid), "unacknowledged overflow must stay out of later whole-file snapshots"); + verify(inventory, times(1)).addItem(item); + } finally { release.countDown(); } + } + + @Test + public void pendingFinalPublicationFencesPreviouslyQueuedOverflowDropFallback() throws Exception { + Fixture fixture = createFixture(); + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); PlayerInventory inventory = mock(PlayerInventory.class); + World world = mock(World.class); ItemStack item = mock(ItemStack.class), excess = mock(ItemStack.class); + when(fixture.plugin.getOptions().isDropOnFullInv()).thenReturn(false); + when(player.getUniqueId()).thenReturn(uuid); when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); when(player.getWorld()).thenReturn(world); + when(inventory.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, excess))); + fixture.handler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + AtomicReference first = new AtomicReference<>(), fallback = new AtomicReference<>(); + CountDownLatch fallbackScheduled = new CountDownLatch(1); + doAnswer(call -> { + Runnable task = call.getArgument(1); + if (!first.compareAndSet(null, task)) { fallback.set(task); fallbackScheduled.countDown(); } + return null; + }).when(fixture.bukkitScheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(player)); + fixture.handler.getTimer().shutdownNow(); // Queue the fallback before any owner save. + CompletionStage delivery = fixture.handler.giveItemAsync(player, item); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + first.get().run(); + assertTrue(fallbackScheduled.await(2, TimeUnit.SECONDS)); + var publication = new java.util.concurrent.CompletableFuture(); + doAnswer(call -> { + var candidate = new YamlConfiguration(); + call.getArgument(0, java.util.function.Consumer.class).accept(candidate); + return publication.thenRun(() -> { + fixture.data.set("FullInventory", null); + for (String path : candidate.getKeys(true)) { + if (!candidate.isConfigurationSection(path)) fixture.data.set(path, candidate.get(path)); + } + }); + }).when(fixture.serverData).mutateFinalData(any()); + var finalFiles = fixture.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + assertFalse(finalFiles.isDone()); fallback.get().run(); + verify(world, never()).dropItem(any(Location.class), any(ItemStack.class)); + assertFalse(delivery.toCompletableFuture().isDone()); + assertFalse(fixture.data.contains("FullInventory." + uuid)); + publication.complete(null); finalFiles.get(2, TimeUnit.SECONDS); + assertEquals(excess, fixture.data.getItemStack("FullInventory." + uuid + ".Items.0")); + assertTrue(delivery.toCompletableFuture().isDone()); + assertEquals(java.util.List.of(excess), fixture.handler.getItems().get(uuid)); + } + } + + @Test + public void rejectedAsyncShutdownPersistenceReportsFailureWithoutCallerThreadSave() { + Fixture fixture = createFixture(); + CompletionStage completion = fixture.handler.shutdownAsync(task -> { + throw new java.util.concurrent.RejectedExecutionException("worker stopped"); + }); + assertThrows(java.util.concurrent.CompletionException.class, () -> completion.toCompletableFuture().join()); + verify(fixture.serverData, never()).saveDataChecked(); + assertTrue(fixture.handler.getTimer().isShutdown()); + } + @Test public void shutdownPersistsAcceptedOverflowBeforeDiscardingQueuedPersistence() throws Exception { Fixture fixture = createFixture(); @@ -771,6 +901,393 @@ public void saveWaitsForInflightDeliveryBeforeSnapshotting() throws Exception { assertEquals(excessItem, root.getItemStack(uuid + ".Items.0")); } + @Test void disabledUninitializedServerDataPreservesSafeOverflowFallback() throws Exception { + Fixture f = createFixture(); + when(f.plugin.isLoadServerData()).thenReturn(false); when(f.serverData.getData()).thenReturn(null); + UUID owner = UUID.randomUUID(); var player = mock(Player.class); var slots = mock(PlayerInventory.class); + var input = mock(ItemStack.class); var overflow = mock(ItemStack.class); + var world = mock(World.class); var location = mock(Location.class); + when(player.getUniqueId()).thenReturn(owner); when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(slots); when(player.getWorld()).thenReturn(world); when(player.getLocation()).thenReturn(location); + when(slots.addItem(input)).thenReturn(new HashMap<>(java.util.Map.of(0, overflow))); + when(world.dropItem(location, overflow)).thenReturn(mock(org.bukkit.entity.Item.class)); + f.handler.getLastMessageTime().put(owner, System.currentTimeMillis()); + var tasks = new java.util.concurrent.LinkedBlockingQueue(); + doAnswer(call -> { tasks.add(call.getArgument(1)); return null; }) + .when(f.bukkitScheduler).runTask(eq(f.plugin), any(Runnable.class), eq(player)); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(owner)).thenReturn(player); + var receipt = f.handler.giveItemAsync(player, input).toCompletableFuture(); + tasks.poll(3, TimeUnit.SECONDS).run(); f.handler.getTimer().submit(() -> {}).get(3, TimeUnit.SECONDS); + Runnable fallback = tasks.poll(3, TimeUnit.SECONDS); assertNotNull(fallback); assertFalse(receipt.isDone()); + fallback.run(); receipt.get(3, TimeUnit.SECONDS); + verify(slots, times(1)).addItem(input); verify(world, times(1)).dropItem(location, overflow); + verify(f.serverData, never()).mutateFinalData(any()); assertFalse(f.handler.getItems().containsKey(owner)); + } + } + + @Test void deferredFixturePublishesControlOnlyAfterOwnerCallbackRegistration() throws Exception { + Fixture f = createFixture(); + var registering = new CountDownLatch(1); + var allowRegistration = new CountDownLatch(1); + var control = new java.util.concurrent.CompletableFuture() { + @Override public java.util.concurrent.CompletableFuture thenRun(Runnable action) { + registering.countDown(); + try { assertTrue(allowRegistration.await(3, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { + Thread.currentThread().interrupt(); + throw new AssertionError(failure); + } + return super.thenRun(action); + } + }; + var submissions = deferredPublications(f, null, () -> control); + UUID owner = UUID.randomUUID(); var item = mock(ItemStack.class); + f.handler.add(owner, item); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + var save = worker.submit(f.handler::saveDurably); + try { + assertTrue(registering.await(3, TimeUnit.SECONDS)); + assertTrue(submissions.isEmpty(), "raw completion must not precede its owner installation callback"); + allowRegistration.countDown(); + assertFalse(save.get(3, TimeUnit.SECONDS)); + var publication = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publication); + publication.getValue().complete(null); + assertEquals(item, f.data.getItemStack("FullInventory." + owner + ".Items.0")); + } finally { + allowRegistration.countDown(); control.complete(null); + worker.shutdown(); assertTrue(worker.awaitTermination(3, TimeUnit.SECONDS)); + } + } + + @Test void reversedOwnerCompletionsCannotEraseLaterSavedItems() throws Exception { + Fixture f = createFixture(); + var submissions = deferredPublications(f); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + var item = mock(ItemStack.class); + f.handler.add(first, item); assertFalse(f.handler.saveDurably()); + var publicationA = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publicationA); + f.handler.add(second, item); assertFalse(f.handler.saveDurably()); + var publicationB = submissions.poll(); + if (publicationB != null) { + // An owner is allowed to finish concurrent submissions in reverse order. + publicationB.getValue().complete(null); publicationA.getValue().complete(null); + } else { + publicationA.getValue().complete(null); + publicationB = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publicationB); + publicationB.getValue().complete(null); + } + assertEquals(item, f.data.getItemStack("FullInventory." + first + ".Items.0")); + assertEquals(item, f.data.getItemStack("FullInventory." + second + ".Items.0"), + "an older snapshot must never erase a later completed save"); + } + + @Test void laterSaveWaitsForEarlierOwnerAndCapturesLatestItems() throws Exception { + Fixture f = createFixture(); + var submissions = deferredPublications(f); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(), latest = UUID.randomUUID(); + var item = mock(ItemStack.class); + f.handler.add(first, item); + assertFalse(f.handler.saveDurably()); + var publicationA = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publicationA); + f.handler.add(second, item); + assertFalse(f.handler.saveDurably()); + // This addition must be captured when B can actually publish, not when B queues. + f.handler.add(latest, item); + assertTrue(submissions.isEmpty(), "a later owner stage must not overtake the pending stage"); + publicationA.getValue().complete(null); + var publicationB = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publicationB); + assertTrue(publicationB.getKey().contains("FullInventory." + latest)); + publicationB.getValue().complete(null); + assertEquals(item, f.data.getItemStack("FullInventory." + first + ".Items.0")); + assertEquals(item, f.data.getItemStack("FullInventory." + second + ".Items.0")); + assertEquals(item, f.data.getItemStack("FullInventory." + latest + ".Items.0")); + } + + @Test void shutdownWaitsForPendingOrdinaryOwnerSave() throws Exception { + Fixture f = createFixture(); + var submissions = deferredPublications(f); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + var item = mock(ItemStack.class); + f.handler.add(first, item); assertFalse(f.handler.saveDurably()); + var publicationA = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publicationA); + f.handler.add(second, item); + var shutdown = f.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + assertFalse(shutdown.isDone()); + assertTrue(submissions.isEmpty(), "shutdown must wait for the outstanding owner stage"); + publicationA.getValue().complete(null); + var finalPublication = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(finalPublication); + finalPublication.getValue().complete(null); shutdown.get(3, TimeUnit.SECONDS); + assertEquals(item, f.data.getItemStack("FullInventory." + first + ".Items.0")); + assertEquals(item, f.data.getItemStack("FullInventory." + second + ".Items.0")); + assertTrue(f.handler.getTimer().isShutdown()); + } + + @Test void repeatedLegacyShutdownReusesPendingAndCompletedPublication() throws Exception { + Fixture f = createFixture(); var submissions = deferredPublications(f); + f.handler.add(UUID.randomUUID(), mock(ItemStack.class)); + f.handler.shutdown(); var last = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(last); + f.handler.shutdown(); + var finalStage = f.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + last.getValue().complete(null); + finalStage.get(3, TimeUnit.SECONDS); + assertTrue(f.handler.getTimer().isShutdown()); + f.handler.shutdown(); + assertTrue(submissions.isEmpty(), "repeated shutdown must not submit stale final writes"); + verify(f.serverData, times(1)).mutateFinalData(any()); + } + + @Test void shutdownDrainsAcceptedSnapshotContinuationBeforeFinalPublication() throws Exception { + Fixture f = createFixture(); var submissions = deferredPublications(f); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); var item = mock(ItemStack.class); + f.handler.add(first, item); assertFalse(f.handler.saveDurably()); + var a = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(a); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var interrupted = new AtomicBoolean(); + f.handler.getTimer().execute(() -> { + entered.countDown(); + try { release.await(5, TimeUnit.SECONDS); } + catch (InterruptedException failure) { interrupted.set(true); } + }); + assertTrue(entered.await(3, TimeUnit.SECONDS)); + try { + f.handler.add(second, item); assertFalse(f.handler.saveDurably()); + // B is accepted by the timer but cannot run until the blocked task drains. + a.getValue().complete(null); + var shutdown = f.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + assertTrue(f.handler.getTimer().isShutdown()); assertFalse(shutdown.isDone()); + release.countDown(); + var b = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(b); + assertFalse(interrupted.get()); assertFalse(shutdown.isDone()); + b.getValue().complete(null); + var last = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(last); + assertFalse(shutdown.isDone()); last.getValue().complete(null); shutdown.get(3, TimeUnit.SECONDS); + assertEquals(item, f.data.getItemStack("FullInventory." + first + ".Items.0")); + assertEquals(item, f.data.getItemStack("FullInventory." + second + ".Items.0")); + } finally { release.countDown(); } + } + + @Test void cancelledShutdownObservationCannotAdvanceActualLifecyclePublications() throws Exception { + verifyShutdownObservationCannotAdvanceActualLifecyclePublications("cancel"); + } + @Test void completedShutdownObservationCannotAdvanceActualLifecyclePublications() throws Exception { + verifyShutdownObservationCannotAdvanceActualLifecyclePublications("complete"); + } + @Test void failedShutdownObservationCannotAdvanceActualLifecyclePublications() throws Exception { + verifyShutdownObservationCannotAdvanceActualLifecyclePublications("fail"); + } + @Test void untouchedShutdownObservationRetainsActualLifecycleOrder() throws Exception { + verifyShutdownObservationCannotAdvanceActualLifecyclePublications("untouched"); + } + private void verifyShutdownObservationCannotAdvanceActualLifecyclePublications(String action) throws Exception { + Fixture f = createFixture(); + when(f.plugin.isLoadServerData()).thenReturn(true); + when(f.plugin.getFullInventoryHandler()).thenReturn(f.handler); + var permissions = mock(com.bencodez.advancedcore.api.permissions.PermissionHandler.class); + when(f.plugin.getPermissionHandler()).thenReturn(permissions); + var publications = new java.util.concurrent.LinkedBlockingQueue>>(); + var controls = new java.util.concurrent.CopyOnWriteArrayList>(); + doAnswer(call -> { + var snapshot = new YamlConfiguration(); + synchronized (f.serverData.publicationMonitor()) { + for (String path : f.data.getKeys(true)) + if (!f.data.isConfigurationSection(path)) snapshot.set(path, f.data.get(path)); + call.getArgument(0, java.util.function.Consumer.class).accept(snapshot); + } + var control = new java.util.concurrent.CompletableFuture(); + controls.add(control); + var installed = control.thenRun(() -> { + synchronized (f.serverData.publicationMonitor()) { + for (String key : new ArrayList<>(f.data.getKeys(false))) f.data.set(key, null); + for (String path : snapshot.getKeys(true)) + if (!snapshot.isConfigurationSection(path)) f.data.set(path, snapshot.get(path)); + } + }); + publications.add(java.util.Map.entry(snapshot, control)); + return installed; + }).when(f.serverData).mutateFinalData(any()); + var permissionsStarted = new CountDownLatch(1); + var permissionSave = new java.util.concurrent.CompletableFuture(); + when(permissions.shutDownAsync(any())).thenAnswer(call -> { + call.getArgument(0, java.util.concurrent.Executor.class).execute(permissionsStarted::countDown); + return permissionSave; + }); + var finalFiles = new AtomicReference>(); + doAnswer(call -> { finalFiles.set(call.getArgument(0)); return null; }) + .when(f.plugin).onShutdownComplete(any(CompletionStage.class)); + UUID owner = UUID.randomUUID(); var item = mock(ItemStack.class); + f.handler.add(owner, item); + var caller = f.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + var inventory = publications.poll(3, TimeUnit.SECONDS); assertNotNull(inventory); + switch (action) { + case "cancel" -> assertTrue(caller.cancel(false)); + case "complete" -> assertTrue(caller.complete(null)); + case "fail" -> assertTrue(caller.completeExceptionally(new IllegalStateException("synthetic caller failure"))); + default -> assertFalse(caller.isDone()); + } + var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(f.plugin); + var workerField = platform.getClass().getDeclaredField("persistenceWorker"); workerField.setAccessible(true); + var worker = (java.util.concurrent.ThreadPoolExecutor) workerField.get(platform); + try { + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("full inventory handler") + || cleanup.name().equals("server data timestamp")) + .forEach(cleanup -> cleanup.action().run()); + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("permission handler")) + .forEach(cleanup -> cleanup.action().run()); + worker.submit(() -> {}).get(3, TimeUnit.SECONDS); + assertTrue(publications.isEmpty(), "timestamp must await the actual inventory owner after caller completion"); + assertEquals(1L, permissionsStarted.getCount()); + var lifecycleObservation = f.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + assertFalse(lifecycleObservation.isDone()); + inventory.getValue().complete(null); + lifecycleObservation.get(3, TimeUnit.SECONDS); + var timestamp = publications.poll(3, TimeUnit.SECONDS); assertNotNull(timestamp); + assertTrue(timestamp.getKey().contains("LastUpdated")); + assertEquals(item, timestamp.getKey().getItemStack("FullInventory." + owner + ".Items.0")); + assertEquals(1L, permissionsStarted.getCount()); + timestamp.getValue().complete(null); + assertTrue(permissionsStarted.await(3, TimeUnit.SECONDS), + "permission publication must start after the timestamp owner settles"); + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("shutdown persistence worker") + || cleanup.name().equals("plugin shutdown completion hook")) + .forEach(cleanup -> cleanup.action().run()); + assertFalse(finalFiles.get().toCompletableFuture().isDone()); + permissionSave.complete(null); + finalFiles.get().toCompletableFuture().get(3, TimeUnit.SECONDS); + assertTrue(worker.awaitTermination(3, TimeUnit.SECONDS)); + assertEquals(item, f.data.getItemStack("FullInventory." + owner + ".Items.0")); + assertTrue(f.data.contains("LastUpdated")); + } finally { + controls.forEach(control -> control.complete(null)); permissionSave.complete(null); + worker.shutdownNow(); assertTrue(worker.awaitTermination(3, TimeUnit.SECONDS)); + } + } + + @Test void cancellingShutdownViewCannotReleasePendingOwner() throws Exception { + Fixture f = createFixture(); var submissions = deferredPublications(f); + UUID owner = UUID.randomUUID(); var item = mock(ItemStack.class); + f.handler.add(owner, item); assertFalse(f.handler.saveDurably()); + var a = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(a); + var shutdown = f.handler.shutdownAsync(Runnable::run).toCompletableFuture(); + assertTrue(shutdown.cancel(false)); assertTrue(submissions.isEmpty()); + assertTrue(f.handler.getTimer().isShutdown()); + a.getValue().complete(null); + var last = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(last); + last.getValue().complete(null); + assertEquals(item, f.data.getItemStack("FullInventory." + owner + ".Items.0")); + } + + @Test void deferredSnapshotRunsOnWorkerInsteadOfOwnerCompletionThread() throws Exception { + Fixture f = createFixture(); + var calls = new java.util.concurrent.LinkedBlockingQueue(); + var submissions = deferredPublications(f, calls); + f.handler.add(UUID.randomUUID(), mock(ItemStack.class)); assertFalse(f.handler.saveDurably()); + var a = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(a); calls.take(); + assertFalse(f.handler.saveDurably()); a.getValue().complete(null); + var b = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(b); + Thread worker = calls.poll(3, TimeUnit.SECONDS); assertNotNull(worker); + assertNotSame(Thread.currentThread(), worker); assertTrue(worker.isDaemon()); + b.getValue().complete(null); + } + + private java.util.concurrent.LinkedBlockingQueue>> deferredPublications(Fixture f) { + return deferredPublications(f, null); + } + + private java.util.concurrent.LinkedBlockingQueue>> deferredPublications(Fixture f, + java.util.Queue threads) { + return deferredPublications(f, threads, java.util.concurrent.CompletableFuture::new); + } + + private java.util.concurrent.LinkedBlockingQueue>> deferredPublications(Fixture f, + java.util.Queue threads, + java.util.function.Supplier> controls) { + var submissions = new java.util.concurrent.LinkedBlockingQueue>>(); + doAnswer(call -> { + if (threads != null) threads.add(Thread.currentThread()); + var candidate = new YamlConfiguration(); + call.getArgument(0, java.util.function.Consumer.class).accept(candidate); + var confirmed = controls.get(); + var publication = confirmed.thenRun(() -> { + synchronized (f.serverData.publicationMonitor()) { + f.data.set("FullInventory", null); + for (String path : candidate.getKeys(true)) + if (!candidate.isConfigurationSection(path)) f.data.set(path, candidate.get(path)); + } + }); + // Completion is observable only after its installation callback exists. + submissions.add(java.util.Map.entry(candidate, confirmed)); + return publication; + }).when(f.serverData).mutateFinalData(any()); + return submissions; + } + + @Test void overlappingSuccessfulAsyncOwnerSavesRetainEveryUnpublishedOverflow() throws Exception { + overlappingAsyncOwnerSavesRetainEveryUnpublishedOverflow(false); + } + @Test void failedEarlierSaveCannotDropOverflowOwnedByAnotherPendingPublication() throws Exception { + overlappingAsyncOwnerSavesRetainEveryUnpublishedOverflow(true); + } + @Test void shutdownConfirmsQueuedOverflowOnlyAfterItsOrderedFinalSave() throws Exception { + overlappingAsyncOwnerSavesRetainEveryUnpublishedOverflow(false, true); + } + private void overlappingAsyncOwnerSavesRetainEveryUnpublishedOverflow(boolean failFirst) throws Exception { + overlappingAsyncOwnerSavesRetainEveryUnpublishedOverflow(failFirst, false); + } + private void overlappingAsyncOwnerSavesRetainEveryUnpublishedOverflow(boolean failFirst, boolean shutdown) throws Exception { + Fixture f = createFixture(); + var submissions = deferredPublications(f); + var ownerTasks = new java.util.concurrent.LinkedBlockingQueue(); + doAnswer(call -> { ownerTasks.add(call.getArgument(1)); return null; }) + .when(f.bukkitScheduler).runTask(eq(f.plugin), any(Runnable.class), any(Player.class)); + UUID first = UUID.randomUUID(), second = UUID.randomUUID(); + var a = mock(Player.class); var b = mock(Player.class); + var slots = mock(PlayerInventory.class); var item = mock(ItemStack.class); + when(a.getUniqueId()).thenReturn(first); when(b.getUniqueId()).thenReturn(second); + for (Player player : List.of(a, b)) { when(player.isOnline()).thenReturn(true); when(player.getInventory()).thenReturn(slots); } + when(slots.addItem(item)).thenReturn(new HashMap<>(java.util.Map.of(0, item))); + f.handler.getLastMessageTime().put(first, System.currentTimeMillis()); + f.handler.getLastMessageTime().put(second, System.currentTimeMillis()); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer(first)).thenReturn(a); bukkit.when(() -> Bukkit.getPlayer(second)).thenReturn(b); + var receiptA = f.handler.giveItemAsync(a, item).toCompletableFuture(); + ownerTasks.poll(3, TimeUnit.SECONDS).run(); var publicationA = submissions.poll(3, TimeUnit.SECONDS); + var receiptB = f.handler.giveItemAsync(b, item).toCompletableFuture(); + ownerTasks.poll(3, TimeUnit.SECONDS).run(); + f.handler.getTimer().submit(() -> {}).get(3, TimeUnit.SECONDS); + assertNotNull(publicationA); + assertTrue(submissions.isEmpty(), "the second snapshot must await the first owner"); + assertFalse(receiptA.isDone()); assertFalse(receiptB.isDone()); assertTrue(f.handler.getItems().isEmpty()); + var finalFiles = shutdown ? f.handler.shutdownAsync(Runnable::run).toCompletableFuture() : null; + if (failFirst) { + publicationA.getValue().completeExceptionally(new IllegalStateException("first publication rejected")); + // An unconfirmed owner save can already be visible on disk. + // It must remain reserved for confirmed retry, never fall back to a drop. + f.handler.getTimer().submit(() -> {}).get(3, TimeUnit.SECONDS); + assertTrue(ownerTasks.isEmpty()); verify(a, never()).getWorld(); assertFalse(receiptA.isDone()); + } else publicationA.getValue().complete(null); + var publicationB = submissions.poll(3, TimeUnit.SECONDS); assertNotNull(publicationB); + assertTrue(publicationB.getKey().contains("FullInventory." + first)); + assertTrue(publicationB.getKey().contains("FullInventory." + second)); + assertFalse(receiptB.isDone()); + publicationB.getValue().complete(null); + if (finalFiles != null) finalFiles.get(3, TimeUnit.SECONDS); + receiptA.get(3, TimeUnit.SECONDS); receiptB.get(3, TimeUnit.SECONDS); + assertEquals(List.of(item), f.handler.getItems().get(first)); + assertEquals(List.of(item), f.handler.getItems().get(second)); + assertEquals(item, f.data.getItemStack("FullInventory." + first + ".Items.0")); + assertEquals(item, f.data.getItemStack("FullInventory." + second + ".Items.0")); + } + } + private Fixture createFixture() { return createFixture(TimeUnit.SECONDS.toMillis(30)); } @@ -781,8 +1298,13 @@ private Fixture createFixture(long itemDeliveryTimeoutMillis) { ScheduledExecutorService sharedInventoryTimer = mock(ScheduledExecutorService.class); BukkitScheduler bukkitScheduler = mock(BukkitScheduler.class); ServerData serverData = mock(ServerData.class); + when(serverData.publicationMonitor()).thenCallRealMethod(); + org.mockito.Mockito.doCallRealMethod().when(serverData).mutateFinalData(any()); + // The fixture's synchronous publisher is the existing controllable saveData stub. + doAnswer(call -> { serverData.saveData(); return null; }).when(serverData).saveDataChecked(); YamlConfiguration data = new YamlConfiguration(); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); when(plugin.getInventoryTimer()).thenReturn(sharedInventoryTimer); when(plugin.getOptions()).thenReturn(options); when(plugin.isEnabled()).thenReturn(true); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryPublicationMonitorTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryPublicationMonitorTest.java new file mode 100644 index 0000000000..cd5030d943 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/item/FullInventoryPublicationMonitorTest.java @@ -0,0 +1,142 @@ +package com.bencodez.advancedcore.tests.item; + +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.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Logger; + +import org.bukkit.Server; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.data.ServerData; + +class FullInventoryPublicationMonitorTest { + @TempDir Path directory; + + @Test void ordinaryNativeOwnerCannotPublishAnIncompleteOverflowSection() throws Exception { + verifyPublication(false, false); + } + + @Test void ordinaryFacadeOwnerCannotPublishAnIncompleteOverflowSection() throws Exception { + verifyPublication(true, false); + } + + @Test void finalNativeOwnerCannotPublishAnIncompleteOverflowSection() throws Exception { + verifyPublication(false, true); + } + + @Test void finalFacadeOwnerCannotPublishAnIncompleteOverflowSection() throws Exception { + verifyPublication(true, true); + } + + private void verifyPublication(boolean wrapped, boolean finalPublication) throws Exception { + ConfigurationSerialization.registerClass(FullInventoryFinalPublicationTest.PersistedItem.class, + "ACFinalOverflowTestItem"); + var plugin = mock(AdvancedCorePlugin.class); + var server = mock(Server.class); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(mock(org.bukkit.scheduler.BukkitScheduler.class)); + when(plugin.getDataFolder()).thenReturn(directory.toFile()); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + var owner = new ServerData(plugin); + var yaml = new GatedYaml(); + owner.setData(yaml); + owner.saveDataChecked(); + ServerData exposed = wrapped ? new Facade(plugin, owner) : owner; + when(plugin.getServerDataFile()).thenReturn(exposed); + var handler = new FullInventoryHandler(plugin); + UUID player = UUID.randomUUID(); + handler.add(player, new FullInventoryFinalPublicationTest.PersistedItem()); + assertTrue(handler.saveDurably()); + assertNotNull(YamlConfiguration.loadConfiguration(owner.getdFile()) + .getItemStack("FullInventory." + player + ".Items.0"), + "the regression starts with real durable recovery data"); + var writer = Executors.newSingleThreadExecutor(); + var checkpoint = Executors.newSingleThreadExecutor(); + var writerThread = new java.util.concurrent.atomic.AtomicReference(); + var checkpointThread = new java.util.concurrent.atomic.AtomicReference(); + yaml.armed.set(true); + Future inventory = writer.submit(() -> { + writerThread.set(Thread.currentThread()); + return handler.saveDurably(); + }); + try { + assertTrue(yaml.cleared.await(3, TimeUnit.SECONDS)); + Future other = checkpoint.submit(() -> { + checkpointThread.set(Thread.currentThread()); + if (finalPublication) owner.mutateFinalData(data -> data.set("VoteParty.Votes", 7)) + .toCompletableFuture().join(); + else owner.setValue("VoteParty.Votes", 7); + }); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + boolean blockedOnActualOwner = false; + while (!other.isDone() && System.nanoTime() < deadline) { + Thread thread = checkpointThread.get(); + var info = thread == null ? null : ManagementFactory.getThreadMXBean().getThreadInfo(thread.threadId()); + if (info != null && info.getThreadState() == Thread.State.BLOCKED + && info.getLockOwnerId() == writerThread.get().threadId() + && info.getLockInfo().getIdentityHashCode() == System.identityHashCode(owner)) { + blockedOnActualOwner = true; + break; + } + Thread.yield(); + } + if (other.isDone()) { + other.get(3, TimeUnit.SECONDS); + assertTrue(YamlConfiguration.loadConfiguration(owner.getdFile()).contains("FullInventory"), + "another real whole-file publication persisted the temporary section removal"); + } + assertTrue(blockedOnActualOwner, "publication must wait for the actual YAML owner's transaction"); + yaml.release.countDown(); + assertTrue(inventory.get(3, TimeUnit.SECONDS)); + other.get(3, TimeUnit.SECONDS); + var stored = YamlConfiguration.loadConfiguration(owner.getdFile()); + assertNotNull(stored.getItemStack("FullInventory." + player + ".Items.0")); + assertEquals(7, stored.getInt("VoteParty.Votes")); + } finally { + yaml.release.countDown(); + writer.shutdownNow(); + checkpoint.shutdownNow(); + handler.getTimer().shutdownNow(); + assertTrue(writer.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(checkpoint.awaitTermination(5, TimeUnit.SECONDS)); + ConfigurationSerialization.unregisterClass(FullInventoryFinalPublicationTest.PersistedItem.class); + } + } + + private static final class GatedYaml extends YamlConfiguration { + final CountDownLatch cleared = new CountDownLatch(1), release = new CountDownLatch(1); + final AtomicBoolean armed = new AtomicBoolean(); + + @Override public java.util.Set getKeys(boolean deep) { + if (!deep && armed.compareAndSet(true, false)) { + cleared.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new AssertionError(failure); } + } + return super.getKeys(deep); + } + } + + private static final class Facade extends ServerData { + private final ServerData owner; + Facade(AdvancedCorePlugin plugin, ServerData owner) { super(plugin); this.owner = owner; } + @Override public Object publicationMonitor() { return owner.publicationMonitor(); } + @Override public FileConfiguration getData() { return owner.getData(); } + @Override public void saveData() { owner.saveData(); } + @Override public CompletionStage mutateFinalData(java.util.function.Consumer mutation) { + return owner.mutateFinalData(mutation); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java index ffd567a4a0..6370de7d5e 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/AdvancedCoreLifecycleTest.java @@ -55,8 +55,11 @@ public void shutdownCoordinatesExecutorsAndInventoryPersistence() throws Excepti when(plugin.getInventoryTimer()).thenReturn(inventoryTimer); when(plugin.getTimeChecker()).thenReturn(timeChecker); when(timeChecker.getTimer()).thenReturn(timeTimer); + when(timeChecker.beginShutdown()).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); when(plugin.getRewardHandler()).thenReturn(rewardHandler); when(plugin.getFullInventoryHandler()).thenReturn(fullInventoryHandler); + when(fullInventoryHandler.shutdownAsync(org.mockito.ArgumentMatchers.any(java.util.concurrent.Executor.class))) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); new AdvancedCoreLifecycle(plugin).shutdown(); @@ -68,8 +71,8 @@ public void shutdownCoordinatesExecutorsAndInventoryPersistence() throws Excepti verify(loginTimer).awaitTermination(2, TimeUnit.SECONDS); verify(inventoryTimer, times(2)).awaitTermination(1, TimeUnit.SECONDS); verify(timeTimer).awaitTermination(2, TimeUnit.SECONDS); - verify(rewardHandler).shutdown(); - verify(fullInventoryHandler).shutdown(); + verify(rewardHandler).shutdown(TimeUnit.SECONDS.toNanos(10), TimeUnit.NANOSECONDS); + verify(fullInventoryHandler).shutdownAsync(org.mockito.ArgumentMatchers.any(java.util.concurrent.Executor.class)); verify(plugin).onUnLoad(); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeAdmittedRetirementTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeAdmittedRetirementTest.java new file mode 100644 index 0000000000..cefc8c7735 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeAdmittedRetirementTest.java @@ -0,0 +1,811 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.bukkit.Bukkit; +import org.bukkit.Server; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.PersistedQueueReference; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class CoreRuntimeAdmittedRetirementTest { + @TempDir File directory; + + @Test void preparationJustBeforeDeadlineCannotDiscardAcceptedFinalRetirement() throws Exception { + Fixture fixture = new Fixture(); + AtomicLong clock = new AtomicLong(TimeUnit.SECONDS.toNanos(14)); + CompletableFuture preparation = new CompletableFuture<>(); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + when(fixture.plugin.getFullInventoryHandler()).thenReturn(inventory); + when(inventory.shutdownAsync(any())).thenReturn(preparation); + CountDownLatch occupied = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch preparationTimeout = new CountDownLatch(1); + CountDownLatch retirementTimeout = new CountDownLatch(1); + fixture.manager.getTimer().execute(() -> { + assertTrue(Thread.currentThread().isDaemon()); + occupied.countDown(); + awaitIgnoringInterrupt(release); + }); + assertTrue(occupied.await(2, TimeUnit.SECONDS)); + BukkitRuntimePlatform platform = fixture.platform(); + doReturn(1L).when(platform).deferredShutdownTimeoutMillis(); + doAnswer(call -> { + Throwable failure = call.getArgument(1); + if (failure instanceof TimeoutException) { + if (failure.getMessage().startsWith("Consumer storage preparation")) preparationTimeout.countDown(); + if (failure.getMessage().startsWith("Deferred storage retirement")) retirementTimeout.countDown(); + } + return null; + }).when(platform).cleanupFailed(anyString(), any()); + try { + new AdvancedCoreRuntime(platform, clock::get).shutdown(0L); + assertTrue(preparationTimeout.await(2, TimeUnit.SECONDS)); + assertNull(platform.admittedStorageRetirementCompletion(), "pending preparation is not native-task admission"); + clock.set(TimeUnit.SECONDS.toNanos(15) - 1L); + preparation.complete(null); + assertNotNull(platform.admittedStorageRetirementCompletion()); + assertTrue(retirementTimeout.await(2, TimeUnit.SECONDS)); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + assertFalse(fixture.shared.isClosed()); + verify(fixture.mysql, never()).close(); + release.countDown(); + assertDoesNotThrow(() -> platform.beforeExecutorShutdownCompletion().toCompletableFuture() + .get(2, TimeUnit.SECONDS), "accepted final retirement must survive the exhausted watchdog grace"); + assertTrue(fixture.manager.getTimer().awaitTermination(2, TimeUnit.SECONDS)); + verify(fixture.cache, times(1)).flush(fixture.uuid, UserStorage.MYSQL, fixture.storage); + verify(fixture.backend, times(1)).close(); + verify(fixture.mysql, times(1)).close(); + assertTrue(fixture.shared.isClosed()); + assertEquals(TimeUnit.SECONDS.toNanos(15) - 1L, clock.get()); + } finally { + release.countDown(); + fixture.manager.getTimer().shutdownNow(); + } + } + + @Test void admittedTimedRewardFinishesBeforeNativeRetirementEvenAfterCallerDeadline() throws Exception { + assertAdmittedTimedRewardFinishesBeforeNativeRetirement("normal"); + } + + @Test void failedPreparationStillWaitsForAdmittedTimedReward() throws Exception { + assertAdmittedTimedRewardFinishesBeforeNativeRetirement("failed"); + } + + @Test void cancelledPreparationStillWaitsForAdmittedTimedReward() throws Exception { + assertAdmittedTimedRewardFinishesBeforeNativeRetirement("cancelled"); + } + + @Test void failedTimeRetirementStillWaitsForAdmittedTimedReward() throws Exception { + assertAdmittedTimedRewardFinishesBeforeNativeRetirement("time-failed"); + } + + @Test void cancelledTimeRetirementStillWaitsForAdmittedTimedReward() throws Exception { + assertAdmittedTimedRewardFinishesBeforeNativeRetirement("time-cancelled"); + } + + @Test void admittedExternalTimedCheckDrainsBeforeReplaySnapshot() throws Exception { + assertAdmittedTimedRewardFinishesBeforeNativeRetirement("external"); + } + + private void assertAdmittedTimedRewardFinishesBeforeNativeRetirement(String preparationOutcome) + throws Exception { + Fixture fixture = new Fixture(); + AtomicLong clock = new AtomicLong(TimeUnit.SECONDS.toNanos(15)); + RewardHandler rewards = spy(new RewardHandler(fixture.plugin)); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "shutdown-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + CountDownLatch reading = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean nativeClosed = new AtomicBoolean(); + doAnswer(call -> { nativeClosed.set(true); return null; }).when(fixture.mysql).close(); + ArrayList persisted = new ArrayList<>(List.of("ShutdownReward%ExecutionTime/%1")); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)).thenAnswer(call -> { + reading.countDown(); + while (release.getCount() != 0L) { + try { release.await(); } + catch (InterruptedException ignored) { interrupted.countDown(); } + } + assertFalse(nativeClosed.get(), "admitted timed reward cannot read after its native owner closes"); + return new ArrayList<>(persisted); + }); + doAnswer(call -> { + assertFalse(nativeClosed.get(), "claim/checkpoint/acknowledgement retain native storage ownership"); + persisted.clear(); + persisted.addAll(call.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), anyBoolean()); + doAnswer(call -> { + assertFalse(nativeClosed.get(), "timed acknowledgement retains native storage ownership"); + persisted.clear(); + persisted.addAll(call.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any()); + doReturn(CompletableFuture.completedFuture(null)).when(rewards) + .givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class)); + CompletableFuture hook = new CompletableFuture<>(); + if (preparationOutcome.equals("normal") || preparationOutcome.equals("external") || preparationOutcome.startsWith("time-")) hook.complete(null); + else if (preparationOutcome.equals("failed")) hook.completeExceptionally(new IllegalStateException("consumer failed")); + else hook.cancel(false); + when(fixture.plugin.onBeforeStorageShutdown()).thenReturn(hook); + if (preparationOutcome.startsWith("time-")) { + TimeChecker checker = mock(TimeChecker.class); + CompletableFuture timeRetirement = new CompletableFuture<>(); + if (preparationOutcome.equals("time-failed")) { + timeRetirement.completeExceptionally(new IllegalStateException("time retirement failed")); + } else timeRetirement.cancel(false); + when(fixture.plugin.getTimeChecker()).thenReturn(checker); + when(checker.beginShutdown()).thenReturn(timeRetirement); + } + var external = java.util.concurrent.Executors.newSingleThreadExecutor( + AdvancedCoreRuntime.daemonThreads("external-admitted-replay")); + var execution = preparationOutcome.equals("external") + ? external.submit(user::checkDelayedTimedRewards) + : rewards.getDelayedTimer().submit(user::checkDelayedTimedRewards); + assertTrue(reading.await(2, TimeUnit.SECONDS)); + BukkitRuntimePlatform platform = fixture.platform(); + try { + new AdvancedCoreRuntime(platform, clock::get).shutdown(0L); + if (!preparationOutcome.equals("external")) { + assertTrue(interrupted.await(2, TimeUnit.SECONDS), "zero caller budget still cancels queued timer work"); + } + // A real manager-worker barrier removes the race with the old native + // retirement task. The fixed path keeps admission open for the held reward. + fixture.manager.getTimer().submit(() -> null).get(2, TimeUnit.SECONDS); + assertFalse(fixture.shared.isClosed()); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + verify(fixture.mysql, never()).close(); + release.countDown(); + execution.get(2, TimeUnit.SECONDS); + if (preparationOutcome.equals("normal") || preparationOutcome.equals("external")) { + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + } else { + assertThrows(java.util.concurrent.ExecutionException.class, + () -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS)); + } + assertTrue(fixture.manager.getTimer().awaitTermination(2, TimeUnit.SECONDS)); + verify(rewards, times(1)).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + assertTrue(persisted.isEmpty(), "the actual timed queue acknowledgement finishes before final flush"); + verify(fixture.cache, times(1)).flush(fixture.uuid, UserStorage.MYSQL, fixture.storage); + verify(fixture.mysql, times(1)).close(); + assertEquals(TimeUnit.SECONDS.toNanos(15), clock.get(), "accepted work does not renew caller waits"); + } finally { + release.countDown(); + external.shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + private static void awaitIgnoringInterrupt(CountDownLatch release) { + while (release.getCount() != 0L) { + try { release.await(); } + catch (InterruptedException ignored) { /* Model an admitted noncooperative storage call. */ } + } + } + + @Test void detachedTimedAcknowledgementRetainsStorageAfterDelayedWorkerExits() throws Exception { + assertDetachedTimedAcknowledgementRetainsStorage(false, false); + } + + @Test void spigotDeferredTimedCheckRetainsItsDetachedAcknowledgement() throws Exception { + assertDetachedTimedAcknowledgementRetainsStorage(true, false); + } + + @Test void failedDetachedTimedDispatchRestoresItsQueueBeforeNativeRetirement() throws Exception { + assertDetachedTimedAcknowledgementRetainsStorage(false, true); + } + + @Test void queuedTimedStorageCheckRetainsAdmissionAcrossShutdownFence() throws Exception { + assertDetachedTimedAcknowledgementRetainsStorage(true, false, true); + } + + @Test void failedQueuedTimedStorageCheckRestoresBeforeNativeRetirement() throws Exception { + assertDetachedTimedAcknowledgementRetainsStorage(true, true, true); + } + + @Test void rejectedTimedStorageHandoffReleasesReplayAdmission() throws Exception { + assertFailedTimedStorageHandoffReleasesReplayAdmission(false); + } + + @Test void failedTimedPlayerCaptureReleasesReplayAdmission() throws Exception { + assertFailedTimedStorageHandoffReleasesReplayAdmission(true); + } + + @Test void queuedOfflineStorageCheckRetainsAdmissionAcrossShutdownFence() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL, false, false); + } + + @Test void queuedForcedOfflineCheckRetainsAdmissionAcrossShutdownFence() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.FORCED_ASYNC, false, false); + } + + @Test void queuedVoidForcedOfflineCheckRetainsAdmissionAcrossShutdownFence() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.FORCED_VOID, false, false); + } + + @Test void queuedAsyncOfflineCheckRetainsAdmissionAcrossShutdownFence() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL_ASYNC, false, false); + } + + @Test void failedQueuedOfflineCheckRestoresBeforeNativeRetirement() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL, true, false); + } + + @Test void failedQueuedForcedOfflineCheckRestoresBeforeNativeRetirement() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.FORCED_ASYNC, true, false); + } + + @Test void failedQueuedAsyncOfflineCheckRestoresBeforeNativeRetirement() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL_ASYNC, true, false); + } + + @Test void cancelledForcedOfflineCallerCannotRetireAcceptedQueuedWork() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.FORCED_ASYNC, false, true); + } + + @Test void cancelledAsyncOfflineCallerCannotRetireAcceptedQueuedWork() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL_ASYNC, false, true); + } + + private enum OfflineEntry { NORMAL, NORMAL_ASYNC, FORCED_ASYNC, FORCED_VOID } + + @Test void disabledOfflineProcessingDoesNotRetainReplayAdmission() throws Exception { + assertOfflineNoOpReleasesAdmission(false); + } + + @Test void emptyOfflineQueuesDoNotRetainReplayAdmission() throws Exception { + assertOfflineNoOpReleasesAdmission(true); + } + + @Test void offlineChecksAfterShutdownFenceDoNotReadQueues() throws Exception { + assertOfflineNoOpReleasesAdmission(true, true); + } + + private void assertOfflineNoOpReleasesAdmission(boolean process) throws Exception { + assertOfflineNoOpReleasesAdmission(process, false); + } + + private void assertOfflineNoOpReleasesAdmission(boolean process, boolean closed) throws Exception { + for (OfflineEntry entry : OfflineEntry.values()) { + Fixture fixture = new Fixture(); + when(fixture.plugin.isEnabled()).thenReturn(true); + when(fixture.plugin.getOptions().isProcessRewards()).thenReturn(process); + when(fixture.users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + var apiWorker = java.util.concurrent.Executors.newSingleThreadScheduledExecutor( + AdvancedCoreRuntime.daemonThreads("empty-offline-api-handoff")); + when(fixture.plugin.getTimer()).thenReturn(apiWorker); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(fixture.plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }).when(scheduler) + .executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class)); + RewardHandler rewards = new RewardHandler(fixture.plugin); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "empty-offline-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + when(data.getStringList("OfflineRewards", UserDataFetchMode.DEFAULT)).thenReturn(new ArrayList<>()); + try { + if (closed) rewards.beginShutdown().toCompletableFuture().get(2, TimeUnit.SECONDS); + java.util.concurrent.CompletionStage completion; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + completion = invokeOffline(user, entry); + } + if (completion != null) completion.toCompletableFuture().get(2, TimeUnit.SECONDS); + fixture.manager.getTimer().submit(() -> null).get(2, TimeUnit.SECONDS); + rewards.beginShutdown().toCompletableFuture().get(2, TimeUnit.SECONDS); + assertFalse(user.hasPendingRewardReplay(), entry.name()); + verify(data, times(process && !closed ? 1 : 0)).getStringList("OfflineRewards", UserDataFetchMode.DEFAULT); + } finally { + apiWorker.shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + } + + @Test void rejectedOfflineStorageHandoffReleasesAdmission() throws Exception { + assertFailedOfflineHandoffReleasesAdmission(OfflineEntry.NORMAL, false); + } + + @Test void failedOfflinePlayerCaptureReleasesAdmission() throws Exception { + assertFailedOfflineHandoffReleasesAdmission(OfflineEntry.NORMAL, true); + } + + @Test void rejectedForcedOfflineStorageHandoffReleasesAdmission() throws Exception { + assertFailedOfflineHandoffReleasesAdmission(OfflineEntry.FORCED_ASYNC, false); + } + + @Test void failedForcedOfflinePlayerCaptureReleasesAdmission() throws Exception { + assertFailedOfflineHandoffReleasesAdmission(OfflineEntry.FORCED_ASYNC, true); + } + + @Test void rejectedAsyncOfflineWorkerHandoffReleasesAdmission() throws Exception { + assertFailedOfflineHandoffReleasesAdmission(OfflineEntry.NORMAL_ASYNC, false); + } + + @Test void failedAsyncOfflinePlayerCaptureReleasesAdmission() throws Exception { + assertFailedOfflineHandoffReleasesAdmission(OfflineEntry.NORMAL_ASYNC, true); + } + + private void assertFailedOfflineHandoffReleasesAdmission(OfflineEntry entry, boolean failCapture) throws Exception { + Fixture fixture = new Fixture(); + when(fixture.plugin.isEnabled()).thenReturn(true); + when(fixture.plugin.getOptions().isProcessRewards()).thenReturn(true); + when(fixture.users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + var apiWorker = java.util.concurrent.Executors.newSingleThreadScheduledExecutor( + AdvancedCoreRuntime.daemonThreads("rejected-offline-api-handoff")); + when(fixture.plugin.getTimer()).thenReturn(apiWorker); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(fixture.plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }).when(scheduler) + .executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class)); + RewardHandler rewards = new RewardHandler(fixture.plugin); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "rejected-offline-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + if (failCapture) when(fixture.plugin.getOptions().isTreatVanishAsOffline()) + .thenThrow(new IllegalStateException("capture failed")); + else if (entry == OfflineEntry.NORMAL_ASYNC) apiWorker.shutdown(); + else fixture.manager.getTimer().shutdown(); + try { + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + Class type = failCapture ? IllegalStateException.class + : java.util.concurrent.RejectedExecutionException.class; + if (entry == OfflineEntry.NORMAL || (failCapture && entry != OfflineEntry.NORMAL_ASYNC)) { + assertThrows(type, () -> invokeOffline(user, entry)); + } else { + var completion = invokeOffline(user, entry); + Throwable failure = assertThrows(java.util.concurrent.ExecutionException.class, + () -> completion.toCompletableFuture().get(2, TimeUnit.SECONDS)).getCause(); + assertInstanceOf(type, failure); + } + } + rewards.beginShutdown().toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(data, never()).getStringList(eq("OfflineRewards"), any()); + } finally { + apiWorker.shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + @Test void cancelledAsyncOfflineCallerStillReleasesFailedOwnerCaptureAdmission() throws Exception { + Fixture fixture = new Fixture(); + when(fixture.plugin.isEnabled()).thenReturn(true); + when(fixture.plugin.getOptions().isProcessRewards()).thenReturn(true); + when(fixture.plugin.getOptions().isTreatVanishAsOffline()).thenThrow(new IllegalStateException("capture failed")); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(fixture.plugin.getBukkitScheduler()).thenReturn(scheduler); + var capture = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { capture.set(call.getArgument(1)); return null; }).when(scheduler) + .executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class)); + RewardHandler rewards = new RewardHandler(fixture.plugin); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "cancelled-capture-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + try { + var completion = user.checkOfflineRewardsAsync().toCompletableFuture(); + assertNotNull(capture.get()); + assertTrue(completion.cancel(false)); + var drain = rewards.beginShutdown().toCompletableFuture(); + assertFalse(drain.isDone(), "caller cancellation cannot discard the owned capture"); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + capture.get().run(); + } + drain.get(2, TimeUnit.SECONDS); + verify(data, never()).getStringList(anyString(), any()); + } finally { + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + private java.util.concurrent.CompletionStage invokeOffline(AdvancedCoreUser user, OfflineEntry entry) { + return switch (entry) { + case NORMAL -> { user.checkOfflineRewards(); yield null; } + case NORMAL_ASYNC -> user.checkOfflineRewardsAsync(); + case FORCED_ASYNC -> user.forceRunOfflineRewardsAsync(); + case FORCED_VOID -> { user.forceRunOfflineRewards(); yield null; } + }; + } + + @Test void cancelledAsyncCallerBeforeSuccessfulCaptureRetainsReplayAndRetirement() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL_ASYNC, false, true, true); + } + + @Test void cancelledAsyncCallerBeforeSuccessfulCaptureRetainsFailedReplayForRetry() throws Exception { + assertQueuedOfflineCheckRetainsStorage(OfflineEntry.NORMAL_ASYNC, true, true, true); + } + + private void assertQueuedOfflineCheckRetainsStorage(OfflineEntry entry, boolean failedDispatch, + boolean cancelCaller) throws Exception { + assertQueuedOfflineCheckRetainsStorage(entry, failedDispatch, cancelCaller, false); + } + + private void assertQueuedOfflineCheckRetainsStorage(OfflineEntry entry, boolean failedDispatch, + boolean cancelCaller, boolean deferCapture) throws Exception { + Fixture fixture = new Fixture(); + when(fixture.plugin.isEnabled()).thenReturn(true); + when(fixture.plugin.getOptions().isProcessRewards()).thenReturn(true); + when(fixture.users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + var apiWorker = java.util.concurrent.Executors.newSingleThreadScheduledExecutor( + AdvancedCoreRuntime.daemonThreads("offline-api-handoff")); + when(fixture.plugin.getTimer()).thenReturn(apiWorker); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(fixture.plugin.getBukkitScheduler()).thenReturn(scheduler); + var capture = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { + Runnable task = call.getArgument(1); + if (deferCapture) capture.set(task); else task.run(); + return null; + }).when(scheduler) + .executeOrScheduleSync(eq(fixture.plugin), any(Runnable.class)); + RewardHandler rewards = spy(new RewardHandler(fixture.plugin)); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "offline-handoff-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + ArrayList persisted = new ArrayList<>(List.of("ShutdownReward")); + AtomicBoolean nativeClosed = new AtomicBoolean(); + doAnswer(call -> { nativeClosed.set(true); return null; }).when(fixture.mysql).close(); + when(data.getStringList("OfflineRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted)); + org.mockito.stubbing.Answer write = call -> { + assertFalse(nativeClosed.get(), "queue handling must retain its native owner"); + persisted.clear(); + persisted.addAll(call.getArgument(1)); + return null; + }; + doAnswer(write).when(data).setStringList(eq("OfflineRewards"), any(), anyBoolean()); + doAnswer(write).when(data).setStringList(eq("OfflineRewards"), any()); + CompletableFuture dispatch = new CompletableFuture<>(); + CountDownLatch dispatched = new CountDownLatch(1); + java.util.concurrent.atomic.AtomicReference dispatchedEntry = new java.util.concurrent.atomic.AtomicReference<>(); + doAnswer(call -> { + dispatchedEntry.set(persisted.get(0)); + dispatched.countDown(); + return dispatch; + }).when(rewards) + .givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class)); + CountDownLatch occupied = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + var handoffWorker = entry == OfflineEntry.NORMAL_ASYNC ? apiWorker : fixture.manager.getTimer(); + handoffWorker.execute(() -> { occupied.countDown(); awaitIgnoringInterrupt(release); }); + assertTrue(occupied.await(2, TimeUnit.SECONDS)); + try { + java.util.concurrent.CompletionStage completion; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + completion = invokeOffline(user, entry); + } + verify(data, never()).getStringList(eq("OfflineRewards"), any()); + if (cancelCaller) assertTrue(completion.toCompletableFuture().cancel(false)); + var checksDrained = rewards.beginShutdown().toCompletableFuture(); + assertFalse(checksDrained.isDone(), "accepted offline work must own admission across its queued handoff"); + if (deferCapture) { + assertNotNull(capture.get()); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + capture.get().run(); + } + assertFalse(checksDrained.isDone(), "successful capture must retain the queued worker's admission"); + verify(data, never()).getStringList(eq("OfflineRewards"), any()); + } + release.countDown(); + assertTrue(dispatched.await(2, TimeUnit.SECONDS), "admitted work must dispatch after the fence closes"); + checksDrained.get(2, TimeUnit.SECONDS); + BukkitRuntimePlatform platform = fixture.platform(); + new AdvancedCoreRuntime(platform).shutdown(0L); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + verify(fixture.mysql, never()).close(); + if (completion != null && !cancelCaller) assertFalse(completion.toCompletableFuture().isDone()); + if (failedDispatch) dispatch.completeExceptionally(new IllegalStateException("dispatch failed")); + else dispatch.complete(null); + if (completion != null && !cancelCaller) completion.toCompletableFuture().get(2, TimeUnit.SECONDS); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + if (failedDispatch) { + assertEquals(1, persisted.size()); + assertEquals(dispatchedEntry.get(), persisted.get(0), + "failed offline replay must preserve the exact occurrence and payload"); + } else assertTrue(persisted.isEmpty()); + verify(fixture.cache, times(1)).flush(fixture.uuid, UserStorage.MYSQL, fixture.storage); + verify(fixture.mysql, times(1)).close(); + } finally { + release.countDown(); + dispatch.complete(null); + apiWorker.shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + private void assertFailedTimedStorageHandoffReleasesReplayAdmission(boolean failCapture) throws Exception { + Fixture fixture = new Fixture(); + RewardHandler rewards = new RewardHandler(fixture.plugin); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "rejected-handoff-fixture"); + if (failCapture) { + when(fixture.plugin.getOptions().isTreatVanishAsOffline()) + .thenThrow(new IllegalStateException("player capture failed")); + } else fixture.manager.getTimer().shutdown(); + try { + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + if (failCapture) assertThrows(IllegalStateException.class, user::checkDelayedTimedRewards); + else assertThrows(java.util.concurrent.RejectedExecutionException.class, user::checkDelayedTimedRewards); + } + rewards.beginShutdown().toCompletableFuture().get(2, TimeUnit.SECONDS); + } finally { + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + private void assertDetachedTimedAcknowledgementRetainsStorage(boolean deferredCheck, boolean failedDispatch) + throws Exception { + assertDetachedTimedAcknowledgementRetainsStorage(deferredCheck, failedDispatch, false); + } + + private void assertDetachedTimedAcknowledgementRetainsStorage(boolean deferredCheck, boolean failedDispatch, + boolean queuedAcrossShutdown) throws Exception { + Fixture fixture = new Fixture(); + AtomicLong clock = new AtomicLong(TimeUnit.SECONDS.toNanos(15)); + RewardHandler rewards = spy(new RewardHandler(fixture.plugin)); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "async-shutdown-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + ArrayList persisted = new ArrayList<>(List.of("ShutdownReward%ExecutionTime/%1")); + AtomicBoolean nativeClosed = new AtomicBoolean(); + doAnswer(call -> { nativeClosed.set(true); return null; }).when(fixture.mysql).close(); + when(data.getStringList("TimedRewards", UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted)); + CountDownLatch acknowledging = new CountDownLatch(1); + CountDownLatch releaseAcknowledgement = new CountDownLatch(1); + doAnswer(call -> { + persisted.clear(); + persisted.addAll(call.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any(), anyBoolean()); + doAnswer(call -> { + acknowledging.countDown(); + awaitIgnoringInterrupt(releaseAcknowledgement); + assertFalse(nativeClosed.get(), "detached timed callback retains its native storage owner"); + persisted.clear(); + persisted.addAll(call.getArgument(1)); + return null; + }).when(data).setStringList(eq("TimedRewards"), any()); + CompletableFuture dispatch = new CompletableFuture<>(); + CountDownLatch dispatched = new CountDownLatch(1); + doAnswer(call -> { dispatched.countDown(); return dispatch; }).when(rewards) + .givePersistedQueueRewardAsync(eq(user), any(PersistedQueueReference.class), any(RewardOptions.class)); + CountDownLatch storageOccupied = new CountDownLatch(1); + CountDownLatch releaseStorage = new CountDownLatch(queuedAcrossShutdown ? 1 : 0); + if (queuedAcrossShutdown) { + fixture.manager.getTimer().execute(() -> { + storageOccupied.countDown(); + awaitIgnoringInterrupt(releaseStorage); + }); + assertTrue(storageOccupied.await(2, TimeUnit.SECONDS)); + } + var checking = rewards.getDelayedTimer().submit(() -> { + if (deferredCheck) { + // Exercise Spigot's caller classification on the actual delayed + // worker; the manager's own worker remains the storage owner. + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertTrue(fixture.manager.mustDeferSharedStorageAccess()); + user.checkDelayedTimedRewards(); + } + } else user.checkDelayedTimedRewards(); + }); + checking.get(2, TimeUnit.SECONDS); + if (!queuedAcrossShutdown) assertTrue(dispatched.await(2, TimeUnit.SECONDS)); + BukkitRuntimePlatform platform = fixture.platform(); + var callback = java.util.concurrent.Executors.newSingleThreadExecutor( + AdvancedCoreRuntime.daemonThreads("held-timed-acknowledgement")); + try { + new AdvancedCoreRuntime(platform, clock::get).shutdown(0L); + if (queuedAcrossShutdown) { + releaseStorage.countDown(); + assertTrue(dispatched.await(2, TimeUnit.SECONDS), + "a timed check admitted before shutdown must dispatch after its queued storage handoff"); + } + fixture.manager.getTimer().submit(() -> null).get(2, TimeUnit.SECONDS); + // The storage barrier does not order the delayed worker's final exit. + assertTrue(rewards.getDelayedTimer().awaitTermination(2, TimeUnit.SECONDS), + "worker exit alone does not finish replay"); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + verify(fixture.mysql, never()).close(); + var acknowledgingCallback = callback.submit(() -> { + if (failedDispatch) dispatch.completeExceptionally(new IllegalStateException("dispatch failed")); + else dispatch.complete(null); + }); + assertTrue(acknowledging.await(2, TimeUnit.SECONDS)); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone(), + "completed dispatch future is not a completed queue acknowledgement"); + verify(fixture.mysql, never()).close(); + releaseAcknowledgement.countDown(); + acknowledgingCallback.get(2, TimeUnit.SECONDS); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + assertTrue(fixture.manager.getTimer().awaitTermination(2, TimeUnit.SECONDS)); + if (failedDispatch) { + assertEquals(1, persisted.size(), "failed dispatch restores a durable retry instead of acknowledging"); + assertTrue(persisted.get(0).contains("%asyncretry%")); + } else assertTrue(persisted.isEmpty()); + verify(fixture.cache, times(1)).flush(fixture.uuid, UserStorage.MYSQL, fixture.storage); + verify(fixture.mysql, times(1)).close(); + assertEquals(TimeUnit.SECONDS.toNanos(15), clock.get()); + } finally { + releaseStorage.countDown(); + releaseAcknowledgement.countDown(); + callback.shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + @Test void consumerCallbackCannotStartNewTimedReplayAfterShutdownFence() throws Exception { + assertConsumerCannotStartLateReplay("timed"); + } + + @Test void consumerCallbackCannotStartNewOfflineReplayAfterShutdownFence() throws Exception { + assertConsumerCannotStartLateReplay("offline"); + } + + @Test void consumerCallbackCannotForceNewOfflineReplayAfterShutdownFence() throws Exception { + assertConsumerCannotStartLateReplay("forced"); + } + + private void assertConsumerCannotStartLateReplay(String kind) throws Exception { + Fixture fixture = new Fixture(); + when(fixture.backend.isOpen()).thenReturn(true); + RewardHandler rewards = spy(new RewardHandler(fixture.plugin)); + when(fixture.plugin.getRewardHandler()).thenReturn(rewards); + AdvancedCoreUser user = new AdvancedCoreUser(fixture.plugin, fixture.uuid, "late-replay-fixture"); + UserData data = mock(UserData.class); + user.setData(data); + when(fixture.plugin.getOptions().isProcessRewards()).thenReturn(true); + when(fixture.users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + String key = kind.equals("timed") ? "TimedRewards" : "OfflineRewards"; + String entry = kind.equals("timed") ? "ShutdownReward%ExecutionTime/%1" : "ShutdownReward"; + ArrayList persisted = new ArrayList<>(List.of(entry)); + when(data.getStringList(key, UserDataFetchMode.DEFAULT)) + .thenAnswer(call -> new ArrayList<>(persisted)); + doAnswer(call -> { + persisted.clear(); + persisted.addAll(call.getArgument(1)); + return null; + }).when(data).setStringList(eq(key), any(), anyBoolean()); + CompletableFuture dispatch = new CompletableFuture<>(); + doReturn(dispatch).when(rewards).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + CompletableFuture hook = new CompletableFuture<>(); + when(fixture.plugin.onBeforeStorageShutdown()).thenReturn(hook); + CountDownLatch admitted = new CountDownLatch(1); + CountDownLatch finishHook = new CountDownLatch(1); + var callback = java.util.concurrent.Executors.newSingleThreadExecutor( + AdvancedCoreRuntime.daemonThreads("late-replay-consumer")); + BukkitRuntimePlatform platform = fixture.platform(); + try { + var execution = callback.submit(() -> fixture.shared.withStorageReadAdmission(() -> { + admitted.countDown(); + awaitIgnoringInterrupt(finishHook); + hook.complete(null); + // The completion callback is still admitted to storage, but + // shutdown has already fenced admission of fresh replay work. + if (kind.equals("timed")) user.checkDelayedTimedRewards(); + else if (kind.equals("offline")) user.checkOfflineRewards(); + else user.forceRunOfflineRewardsAsync().toCompletableFuture().join(); + return null; + })); + assertTrue(admitted.await(2, TimeUnit.SECONDS)); + new AdvancedCoreRuntime(platform, () -> TimeUnit.SECONDS.toNanos(15)).shutdown(0L); + finishHook.countDown(); + execution.get(2, TimeUnit.SECONDS); + verify(rewards, never()).givePersistedQueueRewardAsync(eq(user), + any(PersistedQueueReference.class), any(RewardOptions.class)); + assertEquals(List.of(entry), persisted, + "late replay remains durable for the next enable instead of extending a snapshotted tail"); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(fixture.mysql, times(1)).close(); + } finally { + finishHook.countDown(); + dispatch.complete(null); + callback.shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + fixture.manager.getTimer().shutdownNow(); + } + } + + private final class Fixture { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final UserManager users = mock(UserManager.class); + final UserDataManager manager = new UserDataManager(plugin); + final SqlUserBackend backend = mock(SqlUserBackend.class); + final SqlUserStorage storage = mock(SqlUserStorage.class); + final UserCacheOwner cache = mock(UserCacheOwner.class); + final MySQL mysql = mock(MySQL.class); + final UUID uuid = UUID.randomUUID(); + final SharedUserDataRuntime shared; + + Fixture() { + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getOptions()).thenReturn(mock(AdvancedCoreConfigOptions.class)); + when(plugin.getOptions().isOnlineMode()).thenReturn(true); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, mysql, null)); + when(cache.cachedUsers()).thenReturn(java.util.Set.of(uuid)); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + when(backend.user(uuid)).thenReturn(storage); + shared = new SharedUserDataRuntime(backend, cache); + manager.bindSharedRuntime(shared); + manager.bindSharedSqlBackend(backend, operation -> shared.withStorageReadAdmission(() -> { + operation.run(); + return null; + })); + } + + BukkitRuntimePlatform platform() { + BukkitRuntimePlatform platform = spy(new BukkitRuntimePlatform(plugin)); + doReturn(false).when(platform).canBlockForPreExecutorShutdown(); + return platform; + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeDeadlineTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeDeadlineTest.java new file mode 100644 index 0000000000..d6d6bcd0de --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeDeadlineTest.java @@ -0,0 +1,289 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.core.platform.RuntimePlatform; +import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class CoreRuntimeDeadlineTest { + @org.junit.jupiter.api.io.TempDir java.io.File directory; + private AdvancedCorePlugin rewardPlugin() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(directory); + return plugin; + } + + @Test void overlappingExecutorAndActualRewardWaitsShareOneDisableDeadline() throws Exception { + AtomicLong clock = new AtomicLong(); + RuntimePlatform platform = platform(); + ScheduledExecutorService login = timedExecutor(clock); + ScheduledExecutorService storage = timedExecutor(clock); + ScheduledExecutorService time = timedExecutor(clock); + ScheduledExecutorService inventory = timedExecutor(clock); + ScheduledExecutorService separateStorage = timedExecutor(clock); + ScheduledExecutorService delayedRewards = timedExecutor(clock); + RewardHandler rewards = mock(RewardHandler.class, withSettings().useConstructor(rewardPlugin()).defaultAnswer(CALLS_REAL_METHODS)); + var timerField = RewardHandler.class.getDeclaredField("delayedTimer"); + timerField.setAccessible(true); + timerField.set(rewards, delayedRewards); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getRewardHandler()).thenReturn(rewards); + BukkitRuntimePlatform bukkit = new BukkitRuntimePlatform(plugin); + doAnswer(call -> { bukkit.beginShutdown(call.getArgument(0)); return null; }) + .when(platform).beginShutdown(any()); + when(platform.beforeExecutorShutdown()).thenReturn(List.of( + new Cleanup("disable-entry cleanup", () -> clock.addAndGet(TimeUnit.SECONDS.toNanos(3))))); + when(platform.getLoginTimer()).thenReturn(login); + when(platform.getTimer()).thenReturn(storage); + when(platform.getTimeTimer()).thenReturn(time); + when(platform.getInventoryTimer()).thenReturn(inventory); + when(platform.getUserStorageTimer()).thenReturn(separateStorage); + when(platform.afterExecutorGrace()).thenReturn(bukkit.afterExecutorGrace()); + + new AdvancedCoreRuntime(platform, clock::get).shutdown(); + + assertEquals(TimeUnit.SECONDS.toNanos(15), clock.get()); + verify(login).awaitTermination(2, TimeUnit.SECONDS); + verify(storage).awaitTermination(2, TimeUnit.SECONDS); + verify(time).awaitTermination(2, TimeUnit.SECONDS); + verify(inventory).awaitTermination(1, TimeUnit.SECONDS); + verify(delayedRewards).awaitTermination(TimeUnit.SECONDS.toNanos(5), TimeUnit.NANOSECONDS); + verify(delayedRewards).shutdownNow(); + verify(separateStorage, never()).awaitTermination(anyLong(), any()); + verify(separateStorage).shutdownNow(); + } + + @Test void preShutdownWaitIncludesTimeAlreadySpentSincePluginDisableEntry() { + AtomicLong clock = new AtomicLong(TimeUnit.SECONDS.toNanos(14)); + AtomicLong requested = new AtomicLong(); + CompletableFuture pending = new CompletableFuture<>() { + @Override public Void get(long timeout, TimeUnit unit) throws TimeoutException { + requested.set(unit.toNanos(timeout)); + clock.addAndGet(unit.toNanos(timeout)); + throw new TimeoutException("still running"); + } + }; + RuntimePlatform platform = platform(); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(pending); + + new AdvancedCoreRuntime(platform, clock::get).shutdown(0L); + + assertEquals(TimeUnit.SECONDS.toNanos(1), requested.get()); + assertEquals(TimeUnit.SECONDS.toNanos(15), clock.get()); + assertFalse(pending.isDone(), "a deadline must not fabricate storage completion"); + } + + @Test void expiredDeferredDeadlineInterruptsBlockedDaemonWorkerWithoutClosingItsOwner() throws Exception { + RuntimePlatform platform = platform(); + AtomicLong clock = new AtomicLong(TimeUnit.SECONDS.toNanos(15)); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch interrupted = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch ownerClosed = new CountDownLatch(1); + ScheduledExecutorService storage = java.util.concurrent.Executors.newSingleThreadScheduledExecutor( + AdvancedCoreRuntime.daemonThreads("deadline-storage-fixture")); + CompletableFuture retirement = new CompletableFuture<>(); + storage.execute(() -> { + assertTrue(Thread.currentThread().isDaemon()); + started.countDown(); + while (release.getCount() != 0) { + try { release.await(); } + catch (InterruptedException ignored) { interrupted.countDown(); } + } + }); + assertTrue(started.await(2, TimeUnit.SECONDS)); + storage.execute(() -> retirement.complete(null)); + when(platform.getUserStorageTimer()).thenReturn(storage); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retirement); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.afterStorageExecutorShutdown()).thenReturn(List.of(new Cleanup("native owner", ownerClosed::countDown))); + try { + new AdvancedCoreRuntime(platform, clock::get).shutdown(0L); + assertTrue(interrupted.await(2, TimeUnit.SECONDS), "watchdog must not start a fresh five-second grace"); + assertFalse(storage.isTerminated()); + assertFalse(retirement.isDone()); + assertEquals(1L, ownerClosed.getCount(), "running JDBC retains its native owner"); + } finally { + release.countDown(); + storage.shutdownNow(); + } + assertTrue(ownerClosed.await(2, TimeUnit.SECONDS)); + assertFalse(retirement.isDone(), "discarded queued retirement remains observable as pending"); + } + + @Test void everySharedRuntimeExecutorUsesAnOwnedDaemonThread() throws Exception { + var group = AdvancedCoreRuntime.createExecutors(); + try { + for (ScheduledExecutorService executor : List.of(group.timer(), group.loginTimer(), group.inventoryTimer())) { + assertTrue(executor.submit(() -> Thread.currentThread().isDaemon()).get(2, TimeUnit.SECONDS)); + } + } finally { + group.timer().shutdownNow(); group.loginTimer().shutdownNow(); group.inventoryTimer().shutdownNow(); + } + } + + @Test void nativeProviderCloseWithoutManagerCannotBlockLifecycleCaller() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, mysql, null)); + CountDownLatch closing = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(call -> { + assertTrue(Thread.currentThread().isDaemon()); closing.countDown(); + assertTrue(release.await(2, TimeUnit.SECONDS)); return null; + }).when(mysql).close(); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + try { + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + assertTrue(closing.await(2, TimeUnit.SECONDS)); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + release.countDown(); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + verify(plugin).closePendingNativeUserStorageOwners(); + } finally { release.countDown(); } + } + + @Test void actualTimeAndDelayedRewardTimersAreDaemon() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(directory); + RewardHandler rewards = new RewardHandler(plugin); + TimeChecker checker = new TimeChecker(plugin); + try { + checker.loadTimer(); + assertTrue(checker.getTimer().submit(() -> Thread.currentThread().isDaemon()).get(2, TimeUnit.SECONDS)); + assertTrue(rewards.getDelayedTimer().submit(() -> Thread.currentThread().isDaemon()).get(2, TimeUnit.SECONDS)); + } finally { + if (checker.getTimer() != null) checker.getTimer().shutdownNow(); + rewards.shutdown(0, TimeUnit.NANOSECONDS); + } + } + + @Test void standaloneRewardShutdownKeepsItsExistingTenSecondGrace() throws Exception { + RewardHandler rewards = mock(RewardHandler.class, withSettings().useConstructor(rewardPlugin()).defaultAnswer(CALLS_REAL_METHODS)); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + var timerField = RewardHandler.class.getDeclaredField("delayedTimer"); + timerField.setAccessible(true); timerField.set(rewards, timer); + rewards.shutdown(); + verify(timer).shutdown(); + verify(timer).awaitTermination(10, TimeUnit.SECONDS); + verify(timer).shutdownNow(); + } + + @Test void inventoryCompletionAfterDeadlineStillRunsTheActualRetirementFlushOnce() throws Exception { + AtomicLong clock = new AtomicLong(TimeUnit.SECONDS.toNanos(15)); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + CompletableFuture inventoryPersistence = new CompletableFuture<>(); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + when(inventory.shutdownAsync(any())).thenReturn(inventoryPersistence); + MySQL mysql = mock(MySQL.class); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, mysql, null)); + SqlUserBackend backend = mock(SqlUserBackend.class); + SqlUserStorage storage = mock(SqlUserStorage.class); + UserCacheOwner cache = mock(UserCacheOwner.class); + java.util.UUID uuid = java.util.UUID.randomUUID(); + when(cache.cachedUsers()).thenReturn(java.util.Set.of(uuid)); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + when(backend.user(uuid)).thenReturn(storage); + SharedUserDataRuntime shared = new SharedUserDataRuntime(backend, cache); + manager.bindSharedRuntime(shared); + CountDownLatch preparationDeadline = new CountDownLatch(1); + CountDownLatch flushing = new CountDownLatch(1); + CountDownLatch releaseFlush = new CountDownLatch(1); + doAnswer(call -> { + assertTrue(Thread.currentThread().isDaemon()); flushing.countDown(); + assertTrue(releaseFlush.await(2, TimeUnit.SECONDS)); return null; + }).when(cache).flush(uuid, UserStorage.MYSQL, storage); + BukkitRuntimePlatform platform = spy(new BukkitRuntimePlatform(plugin)); + doReturn(false).when(platform).canBlockForPreExecutorShutdown(); + doAnswer(call -> { + Throwable failure = call.getArgument(1); + if (failure instanceof TimeoutException && failure.getMessage().startsWith("Consumer storage preparation")) { + preparationDeadline.countDown(); + } + return call.callRealMethod(); + }).when(platform).cleanupFailed(anyString(), any()); + try { + new AdvancedCoreRuntime(platform, clock::get).shutdown(0L); + assertTrue(preparationDeadline.await(2, TimeUnit.SECONDS)); + assertFalse(manager.getTimer().isShutdown()); + verify(cache, never()).flush(any(), any(UserStorage.class), any()); + inventoryPersistence.complete(null); + assertTrue(flushing.await(2, TimeUnit.SECONDS), "late preparation must admit the real final cache flush"); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + assertFalse(manager.getTimer().isShutdown(), "expired caller budget must not cancel the new checkpoint"); + verify(mysql, never()).close(); + assertEquals(TimeUnit.SECONDS.toNanos(15), clock.get(), "completion must not renew the caller's wait budget"); + releaseFlush.countDown(); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + assertTrue(manager.getTimer().awaitTermination(2, TimeUnit.SECONDS)); + verify(cache, times(1)).flush(uuid, UserStorage.MYSQL, storage); + verify(backend, times(1)).close(); + verify(mysql, times(1)).close(); + assertTrue(shared.isClosed()); + } finally { releaseFlush.countDown(); manager.getTimer().shutdownNow(); } + } + + private RuntimePlatform platform() { + RuntimePlatform platform = mock(RuntimePlatform.class); + // This optional native-admission signal defaults to null on real platforms. + when(platform.admittedStorageRetirementCompletion()).thenReturn(null); + when(platform.beforeExecutorShutdown()).thenReturn(List.of()); + when(platform.afterExecutorGrace()).thenReturn(List.of()); + when(platform.afterExecutorShutdown()).thenReturn(List.of()); + when(platform.afterStorageExecutorShutdown()).thenReturn(List.of()); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(true); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(5_000L); + return platform; + } + + private ScheduledExecutorService timedExecutor(AtomicLong clock) throws Exception { + ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + AtomicBoolean terminated = new AtomicBoolean(); + when(executor.awaitTermination(anyLong(), any())).thenAnswer(call -> { + long duration = call.getArgument(0); + TimeUnit unit = call.getArgument(1); + clock.addAndGet(unit.toNanos(duration)); + return false; + }); + when(executor.isTerminated()).thenAnswer(call -> terminated.get()); + when(executor.shutdownNow()).thenAnswer(call -> { terminated.set(true); return List.of(); }); + return executor; + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index f471036109..01bcebe6ce 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -11,6 +11,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -88,6 +89,8 @@ class CoreRuntimeTest { private RuntimePlatform platform() { RuntimePlatform platform = mock(RuntimePlatform.class); + // This optional native-admission signal defaults to null on real platforms. + when(platform.admittedStorageRetirementCompletion()).thenReturn(null); when(platform.beforeExecutorShutdown()).thenReturn(List.of()); when(platform.afterExecutorGrace()).thenReturn(List.of()); when(platform.afterStorageExecutorShutdown()).thenReturn(List.of()); @@ -161,11 +164,22 @@ private RuntimePlatform platform() { verify(platform, times(1)).getTimeTimer(); } + private ScheduledExecutorService terminatingExecutor(boolean gracefulTermination) { + ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + var terminated = new java.util.concurrent.atomic.AtomicBoolean(); + when(executor.isTerminated()).thenAnswer(call -> terminated.get()); + doAnswer(call -> { terminated.set(gracefulTermination); return null; }).when(executor).shutdown(); + when(executor.shutdownNow()).thenAnswer(call -> { terminated.set(true); return List.of(); }); + return executor; + } + private ScheduledExecutorService executor(String name, List events) throws Exception { ScheduledExecutorService executor = mock(ScheduledExecutorService.class); + var terminated = new java.util.concurrent.atomic.AtomicBoolean(); + when(executor.isTerminated()).thenAnswer(call -> terminated.get()); doAnswer(call -> { events.add(name + "-stop"); return null; }).when(executor).shutdown(); when(executor.awaitTermination(anyLong(), any())).thenAnswer(call -> { events.add(name + "-wait"); return false; }); - when(executor.shutdownNow()).thenAnswer(call -> { events.add(name + "-force"); return List.of(); }); + when(executor.shutdownNow()).thenAnswer(call -> { events.add(name + "-force"); terminated.set(true); return List.of(); }); return executor; } @@ -222,7 +236,7 @@ private ScheduledExecutorService executor(String name, List events) thro @Test void nonBlockingPlatformLeavesStorageWorkerAliveUntilRetirementCompletes() throws Exception { RuntimePlatform platform = platform(); - ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledExecutorService timer = terminatingExecutor(true); CompletableFuture retiring = new CompletableFuture<>(); List events = new java.util.concurrent.CopyOnWriteArrayList<>(); when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); @@ -313,7 +327,7 @@ private ScheduledExecutorService executor(String name, List events) thro @Test void deferredRetirementTimeoutForcesStorageWorkerWithoutRepeatingPlatformCleanup() { RuntimePlatform platform = platform(); - ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledExecutorService timer = terminatingExecutor(false); CompletableFuture retiring = new CompletableFuture<>(); List events = new java.util.concurrent.CopyOnWriteArrayList<>(); when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); @@ -336,6 +350,117 @@ private ScheduledExecutorService executor(String name, List events) thro assertEquals(List.of("unload"), events); } + @Test void pendingPreparationRetainsIdleWorkerThenAdmittedRetirementStillTimesOut() throws Exception { + RuntimePlatform platform = platform(); + var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> { + Thread worker = new Thread(task, "pending-preparation-test"); + worker.setDaemon(true); + return worker; + }); + var preparation = new CompletableFuture(); + var retirement = new CompletableFuture(); + CountDownLatch reported = new CountDownLatch(1); + CountDownLatch terminal = new CountDownLatch(1); + when(platform.storageRetirementPreparationCompletion()).thenReturn(preparation); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retirement); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(20L); + when(platform.getUserStorageTimer()).thenReturn(timer); + when(platform.afterStorageExecutorShutdown()).thenReturn(List.of( + new Cleanup("native storage", terminal::countDown))); + doAnswer(call -> { reported.countDown(); return null; }).when(platform) + .cleanupFailed(eq("pre-executor shutdown"), any(java.util.concurrent.TimeoutException.class)); + try { + new AdvancedCoreRuntime(platform).shutdown(); + assertTrue(reported.await(2, TimeUnit.SECONDS)); + assertFalse(timer.isShutdown(), "idle storage must stay open for a separate consumer checkpoint"); + assertEquals(1, terminal.getCount()); + preparation.complete(null); + assertTrue(terminal.await(2, TimeUnit.SECONDS), "admitted retirement still has a bounded watchdog"); + assertTrue(timer.isTerminated()); + assertFalse(retirement.isDone(), "timeout does not fabricate a successful flush"); + verify(platform, times(2)).cleanupFailed(eq("pre-executor shutdown"), + any(java.util.concurrent.TimeoutException.class)); + } finally { + preparation.complete(null); + retirement.complete(null); + timer.shutdownNow(); + } + } + + @Test void pendingPreparationDoesNotRetainFencedNonDaemonTimeWorker() throws Exception { + RuntimePlatform platform = platform(); + AtomicReference timeWorker = new AtomicReference<>(); + AtomicReference storageWorker = new AtomicReference<>(); + var timeTimer = Executors.newSingleThreadScheduledExecutor(task -> { + Thread worker = new Thread(task, "held-calendar-test"); + worker.setDaemon(false); + timeWorker.set(worker); + return worker; + }); + var storageTimer = Executors.newSingleThreadScheduledExecutor(task -> { + Thread worker = new Thread(task, "retained-storage-test"); + worker.setDaemon(true); + storageWorker.set(worker); + return worker; + }); + var preparation = new CompletableFuture(); + var retirement = new CompletableFuture(); + var fenced = new java.util.concurrent.atomic.AtomicBoolean(); + var interruptedAfterFence = new java.util.concurrent.atomic.AtomicBoolean(); + CountDownLatch started = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + CountDownLatch reported = new CountDownLatch(1); + CountDownLatch checkpoint = new CountDownLatch(1); + CountDownLatch terminal = new CountDownLatch(1); + when(platform.getTimeTimer()).thenReturn(timeTimer); + when(platform.getTimer()).thenReturn(storageTimer); + when(platform.getUserStorageTimer()).thenReturn(storageTimer); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retirement); + when(platform.storageRetirementPreparationCompletion()).thenReturn(preparation); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.holdTimeTimerUntilPreExecutorShutdownCompletion()).thenReturn(true); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(20L); + doAnswer(call -> { fenced.set(true); return null; }).when(platform).beforeForcedTimeTimerShutdown(); + doAnswer(call -> { reported.countDown(); return null; }).when(platform) + .cleanupFailed(eq("pre-executor shutdown"), any(java.util.concurrent.TimeoutException.class)); + when(platform.afterStorageExecutorShutdown()).thenReturn(List.of( + new Cleanup("native storage", terminal::countDown))); + try { + timeTimer.execute(() -> { + started.countDown(); + try { release.await(); } + catch (InterruptedException interrupted) { + interruptedAfterFence.set(fenced.get()); + Thread.currentThread().interrupt(); + } + }); + assertTrue(started.await(2, TimeUnit.SECONDS)); + assertFalse(timeWorker.get().isDaemon(), "fixture must exercise the calendar's non-daemon owner"); + new AdvancedCoreRuntime(platform).shutdown(); + assertTrue(reported.await(2, TimeUnit.SECONDS)); + assertTrue(timeTimer.awaitTermination(2, TimeUnit.SECONDS), + "first watchdog must interrupt the held calendar without awaiting consumer preparation"); + timeWorker.get().join(1_000L); + assertFalse(timeWorker.get().isAlive(), "no non-daemon calendar worker may remain"); + assertTrue(interruptedAfterFence.get(), "recovery authority must be fenced before interruption"); + assertFalse(preparation.isDone(), "fixture preparation remains permanently incomplete"); + assertFalse(retirement.isDone(), "interruption does not fabricate storage completion"); + assertFalse(storageTimer.isShutdown(), "consumer checkpoint admission must remain open"); + storageTimer.execute(checkpoint::countDown); + assertTrue(checkpoint.await(2, TimeUnit.SECONDS)); + assertTrue(storageWorker.get().isDaemon(), "only the daemon storage owner remains retained"); + assertEquals(1, terminal.getCount(), "native storage cannot close before consumer preparation"); + verify(platform, never()).afterStorageExecutorShutdown(); + } finally { + release.countDown(); + timeTimer.shutdownNow(); + storageTimer.shutdownNow(); + assertTrue(timeTimer.awaitTermination(2, TimeUnit.SECONDS)); + assertTrue(storageTimer.awaitTermination(2, TimeUnit.SECONDS)); + } + } + @Test void watchdogLetsQueuedRetirementFlushBeforeForcingStorageWorker() throws Exception { RuntimePlatform platform = platform(); var timer = java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); @@ -433,7 +558,7 @@ private ScheduledExecutorService executor(String name, List events) thro @Test void failedRetirementTerminatesStorageWorkerAfterReportingTheFailure() { RuntimePlatform platform = platform(); - ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + ScheduledExecutorService timer = terminatingExecutor(false); CompletableFuture retiring = new CompletableFuture<>(); retiring.completeExceptionally(new IllegalStateException("write failed")); when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); @@ -476,12 +601,14 @@ private ScheduledExecutorService executor(String name, List events) thro assertDoesNotThrow(() -> new AdvancedCoreLifecycle(null).shutdown()); } - @Test void bukkitAdapterFlushesFullInventoryBeforeExecutorShutdown() { + @Test void bukkitAdapterRetainsStorageUntilFinalInventorySnapshotCompletes() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); FullInventoryHandler handler = mock(FullInventoryHandler.class); MySQL mysql = mock(MySQL.class); AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); when(plugin.getFullInventoryHandler()).thenReturn(handler); + CompletableFuture inventoryPersistence = new CompletableFuture<>(); + when(handler.shutdownAsync(any())).thenReturn(inventoryPersistence); when(plugin.isLoadUserData()).thenReturn(true); when(plugin.getOptions()).thenReturn(options); when(options.getStorageType()).thenReturn(UserStorage.MYSQL); @@ -492,18 +619,61 @@ private ScheduledExecutorService executor(String name, List events) thro .filter(cleanup -> cleanup.name().equals("full inventory handler")) .findFirst().orElseThrow().action().run(); - verify(handler).shutdown(); + verify(handler).shutdownAsync(any()); assertTrue(platform.beforeExecutorShutdown().stream() .noneMatch(cleanup -> cleanup.name().equals("MySQL"))); platform.beforeExecutorShutdown().stream() .filter(cleanup -> cleanup.name().equals("user storage")) .findFirst().orElseThrow().action().run(); - verify(mysql).close(); + verify(mysql, never()).close(); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + inventoryPersistence.complete(null); + verify(mysql, timeout(2_000)).close(); assertTrue(platform.afterExecutorShutdown().stream() .noneMatch(cleanup -> cleanup.name().equals("full inventory handler") || cleanup.name().equals("MySQL"))); } + @Test void failedConsumerHookCannotOvertakePendingInventoryPersistence() { + for (String mode : List.of("throwing", "failed", "cancelled")) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + CompletableFuture inventoryPersistence = new CompletableFuture<>(); + CompletableFuture hook = new CompletableFuture<>(); + IllegalStateException failure = new IllegalStateException("consumer preparation failed"); + if (mode.equals("throwing")) when(plugin.onBeforeStorageShutdown()).thenThrow(failure); + else { + if (mode.equals("failed")) hook.completeExceptionally(failure); + else hook.cancel(false); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + } + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + when(inventory.shutdownAsync(any())).thenReturn(inventoryPersistence); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + var tasks = queuedStorageWorker(manager); + when(manager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("full inventory handler")) + .findFirst().orElseThrow().action().run(); + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + verify(manager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + inventoryPersistence.complete(null); + tasks.remove().run(); + CompletionException reported = assertThrows(CompletionException.class, + () -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().join()); + if (mode.equals("cancelled")) assertInstanceOf(java.util.concurrent.CancellationException.class, reported.getCause()); + else assertSame(failure, reported.getCause()); + verify(manager).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + } + } + @Test void bukkitAdapterDefersMysqlCloseUntilSharedStorageRetires() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); MySQL mysql = mock(MySQL.class); @@ -535,6 +705,275 @@ private ScheduledExecutorService executor(String name, List events) thro verify(mysql).close(); } + @Test void bukkitAdapterWaitsForPluginAdmissionHookBeforeRetiringStorage() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); + var workerTasks = queuedStorageWorker(dataManager); + CompletableFuture hook = new CompletableFuture<>(); + CompletableFuture retirement = new CompletableFuture<>(); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(dataManager); + when(dataManager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenReturn(retirement); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + + verify(dataManager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + hook.complete(null); + verify(dataManager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + workerTasks.remove().run(); + verify(dataManager).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + hook.complete(null); + retirement.complete(null); + assertDoesNotThrow(() -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().join()); + } + + @Test void bukkitAdapterRetainsAdmittedStorageAcrossDeferredUnload() throws Exception { + for (boolean heldCalendar : List.of(false, true)) { + for (String outcome : List.of("success", "failure", "cancelled")) { + assertAdmittedStorageSurvivesUnload(heldCalendar, outcome); + } + } + } + + private void assertAdmittedStorageSurvivesUnload(boolean heldCalendar, String outcome) throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + UserManager successorUsers = mock(UserManager.class); + UserDataManager successorManager = mock(UserDataManager.class); + when(successorUsers.getDataManager()).thenReturn(successorManager); + AtomicReference currentUsers = new AtomicReference<>(users); + var loadUserData = new java.util.concurrent.atomic.AtomicBoolean(true); + var worker = Executors.newSingleThreadScheduledExecutor(task -> { + Thread thread = new Thread(task, "admitted-storage-test"); + thread.setDaemon(true); + return thread; + }); + var hook = new CompletableFuture(); + var calendar = new CompletableFuture(); + var preparationFailure = new IllegalStateException("consumer preparation failed"); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + MySQL mysql = mock(MySQL.class); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.isLoadUserData()).thenAnswer(ignored -> loadUserData.get()); + when(plugin.getLoadedUserManager()).thenAnswer(ignored -> currentUsers.get()); + when(users.getDataManager()).thenReturn(manager); + when(manager.getTimer()).thenReturn(worker); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, mysql, null)); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + if (heldCalendar) { + TimeChecker checker = mock(TimeChecker.class); + when(plugin.getTimeChecker()).thenReturn(checker); + when(checker.beginShutdown()).thenReturn(calendar); + } + doAnswer(ignored -> { + events.add("unload"); + loadUserData.set(false); + if (heldCalendar) currentUsers.set(successorUsers); + return null; + }).when(plugin).onUnLoad(); + doAnswer(ignored -> { events.add("provider close"); return null; }).when(mysql).close(); + doAnswer(ignored -> { events.add("pending owner cleanup"); return null; }) + .when(plugin).closePendingNativeUserStorageOwners(); + when(manager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenAnswer(call -> { + assertSame(worker, manager.getTimer()); + events.add("shared flush"); + call.getArgument(0, Runnable.class).run(); + return CompletableFuture.completedFuture(null); + }); + BukkitRuntimePlatform platform = spy(new BukkitRuntimePlatform(plugin)); + doReturn(false).when(platform).canBlockForPreExecutorShutdown(); + try { + new AdvancedCoreRuntime(platform).shutdown(); + assertFalse(loadUserData.get(), "actual deferred unload must change the admission flag"); + assertSame(heldCalendar ? successorUsers : users, plugin.getLoadedUserManager()); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + assertFalse(worker.isShutdown(), "admitted storage remains open during preparation"); + verify(manager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + verify(mysql, never()).close(); + if (heldCalendar) calendar.complete(null); + assertSame(worker, platform.getUserStorageTimer(), "unload cannot replace the admitted worker"); + if (outcome.equals("success")) hook.complete(null); + else if (outcome.equals("failure")) hook.completeExceptionally(preparationFailure); + else hook.cancel(false); + var completed = platform.beforeExecutorShutdownCompletion().toCompletableFuture(); + try { completed.get(2, TimeUnit.SECONDS); } + catch (java.util.concurrent.ExecutionException failure) { + if (outcome.equals("success")) throw failure; + } + if (outcome.equals("success")) assertDoesNotThrow(completed::join); + else { + CompletionException failure = assertThrows(CompletionException.class, completed::join); + if (outcome.equals("failure")) assertSame(preparationFailure, failure.getCause()); + else assertInstanceOf(java.util.concurrent.CancellationException.class, failure.getCause()); + } + assertTrue(worker.awaitTermination(2, TimeUnit.SECONDS), "the admitted worker retires after cleanup"); + assertEquals(List.of("unload", "shared flush", "provider close", "pending owner cleanup"), events); + verify(manager).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + verify(successorManager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + verify(mysql, times(1)).close(); + verify(plugin, times(1)).closePendingNativeUserStorageOwners(); + verify(plugin, times(1)).onBeforeStorageShutdown(); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test void disabledUserLoadingStillRetiresItsExistingManagerWorker() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + var worker = Executors.newSingleThreadScheduledExecutor(); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(manager.getTimer()).thenReturn(worker); + try { + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + new AdvancedCoreRuntime(platform).shutdown(); + assertTrue(worker.isTerminated()); + assertSame(worker, platform.getUserStorageTimer()); + verify(manager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + } finally { + worker.shutdownNow(); + assertTrue(worker.awaitTermination(2, TimeUnit.SECONDS)); + } + } + + @Test void bukkitAdapterRetiresStorageAfterEachPreparationFailureAndWaitsForItsCompletion() { + for (String mode : List.of("failed", "cancelled", "throwing", "late-failed", "late-cancelled")) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); + var workerTasks = queuedStorageWorker(dataManager); + var hook = new CompletableFuture(); var retirement = new CompletableFuture(); + var failure = new IllegalStateException("admission hook failed"); + if (mode.equals("failed")) hook.completeExceptionally(failure); + if (mode.equals("cancelled")) hook.cancel(false); + if (mode.equals("throwing")) when(plugin.onBeforeStorageShutdown()).thenThrow(failure); + else when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(dataManager); + when(dataManager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenReturn(retirement); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + Runnable shutdown = platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action(); + assertDoesNotThrow(shutdown::run, mode); + if (mode.startsWith("late")) { + verify(dataManager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + if (mode.equals("late-failed")) hook.completeExceptionally(failure); else hook.cancel(false); + verify(dataManager, never()).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + workerTasks.remove().run(); + } + verify(dataManager).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone(), mode); + retirement.complete(null); + CompletionException reported = assertThrows(CompletionException.class, + () -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().join()); + if (mode.contains("cancelled")) assertInstanceOf(java.util.concurrent.CancellationException.class, reported.getCause()); + else assertSame(failure, reported.getCause()); + shutdown.run(); verify(dataManager, times(1)).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + verify(plugin, times(1)).onBeforeStorageShutdown(); + } + } + + @Test void bukkitAdapterRetainsBothPreparationAndStorageFailures() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); UserDataManager dataManager = mock(UserDataManager.class); + var preparation = new IllegalStateException("preparation failed"); var storage = new IllegalStateException("flush failed"); + when(plugin.onBeforeStorageShutdown()).thenReturn(CompletableFuture.failedFuture(preparation)); + when(plugin.isLoadUserData()).thenReturn(true); when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(dataManager); + when(dataManager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenReturn(CompletableFuture.failedFuture(storage)); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + CompletionException reported = assertThrows(CompletionException.class, + () -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().join()); + assertSame(preparation, reported.getCause()); assertArrayEquals(new Throwable[] {storage}, reported.getSuppressed()); + verify(dataManager).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); + } + + private static java.util.Queue queuedStorageWorker(UserDataManager manager) { + var tasks = new java.util.ArrayDeque(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + when(manager.getTimer()).thenReturn(worker); + doAnswer(call -> { tasks.add(call.getArgument(0, Runnable.class)); return null; }) + .when(worker).execute(any(Runnable.class)); + return tasks; + } + + @Test void defaultAdmissionHookCompletesForExistingSubclasses() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + assertDoesNotThrow(() -> plugin.onBeforeStorageShutdown().toCompletableFuture().join()); + } + + @Test void bukkitAdapterRunsShutdownCompletionHookAfterNativeCleanup() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + List order = new ArrayList<>(); + Thread lifecycleThread = Thread.currentThread(); + doCallRealMethod().when(plugin).onShutdownComplete(any(CompletionStage.class)); + doAnswer(invocation -> { order.add("unload"); return null; }).when(plugin).onUnLoad(); + doAnswer(invocation -> { assertSame(lifecycleThread, Thread.currentThread()); order.add("complete"); return null; }).when(plugin).onShutdownComplete(); + when(plugin.getSkullCacheHandler()).thenReturn(null); + when(plugin.getHologramHandler()).thenReturn(null); + when(plugin.getPermissionHandler()).thenReturn(null); + when(plugin.getDialogService()).thenReturn(null); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + + List cleanups = platform.afterExecutorShutdown(); + assertEquals("plugin shutdown completion hook", cleanups.get(cleanups.size() - 1).name()); + cleanups.forEach(cleanup -> cleanup.action().run()); + assertEquals(List.of("unload", "complete"), order); + verify(plugin).onShutdownComplete(); + } + + @Test void shutdownHookReceivesActualFinalStagesAndRetainsExceptionalSettlement() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isLoadServerData()).thenReturn(true); + var inventory = mock(FullInventoryHandler.class); + var permissions = mock(com.bencodez.advancedcore.api.permissions.PermissionHandler.class); + var data = mock(com.bencodez.advancedcore.data.ServerData.class); + var inventorySave = new CompletableFuture(); var timestampSave = new CompletableFuture(); + var permissionSave = new CompletableFuture(); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + when(plugin.getPermissionHandler()).thenReturn(permissions); + when(plugin.getServerDataFile()).thenReturn(data); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test")); + when(inventory.shutdownAsync(any())).thenReturn(inventorySave); + when(permissions.shutDownAsync(any())).thenReturn(permissionSave); + CountDownLatch admitted = new CountDownLatch(1); + when(data.mutateFinalData(any())).thenAnswer(call -> { admitted.countDown(); return timestampSave; }); + AtomicReference> files = new AtomicReference<>(); + Thread lifecycleThread = Thread.currentThread(); + doAnswer(call -> { + assertSame(lifecycleThread, Thread.currentThread()); files.set(call.getArgument(0)); return null; + }).when(plugin).onShutdownComplete(any(CompletionStage.class)); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("full inventory handler") + || cleanup.name().equals("server data timestamp")).forEach(cleanup -> cleanup.action().run()); + inventorySave.complete(null); + assertTrue(admitted.await(2, TimeUnit.SECONDS)); + platform.afterExecutorShutdown().forEach(cleanup -> cleanup.action().run()); + assertNotNull(files.get()); assertFalse(files.get().toCompletableFuture().isDone()); + var failedPermission = new IllegalStateException("permission publication failed"); + permissionSave.completeExceptionally(failedPermission); + assertFalse(files.get().toCompletableFuture().isDone(), "aggregate waits for every owner settlement"); + timestampSave.complete(null); + assertSame(failedPermission, assertThrows(CompletionException.class, + () -> files.get().toCompletableFuture().join()).getCause()); + } + @Test void bukkitAdapterDoesNotRetireStorageBeforeAnAdmittedTimeTransitionDrains() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); TimeChecker checker = mock(TimeChecker.class); @@ -561,6 +1000,7 @@ private ScheduledExecutorService executor(String name, List events) thro assertTrue(platform.holdTimeTimerUntilPreExecutorShutdownCompletion()); platform.beforeDeferredPlatformCleanup(); verify(checker).cancelActiveTransitionsAndClosePersistence(); + verify(checker).cancelQueuedUpdate(); transitionDrain.complete(null); verify(dataManager).closeSharedRuntimeAsyncCompletion(any(Runnable.class)); assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); @@ -650,8 +1090,8 @@ private ScheduledExecutorService executor(String name, List events) thro verify(mysql, never()).close(); platform.afterStorageExecutorShutdown().get(0).action().run(); - verify(mysql).close(); - verify(plugin).closePendingNativeUserStorageOwners(); + verify(mysql, timeout(2_000)).close(); + verify(plugin, timeout(2_000)).closePendingNativeUserStorageOwners(); } @Test void bukkitAdapterClosesOwnerAfterWatchdogCancelsQueuedRetirement() { @@ -676,8 +1116,8 @@ private ScheduledExecutorService executor(String name, List events) thro .findFirst().orElseThrow().action().run(); platform.afterStorageExecutorShutdown().get(0).action().run(); - verify(mysql).close(); - verify(plugin).closePendingNativeUserStorageOwners(); + verify(mysql, timeout(2_000)).close(); + verify(plugin, timeout(2_000)).closePendingNativeUserStorageOwners(); assertFalse(retirement.isDone()); lateCompletion.get().run(); verify(mysql, times(1)).close(); @@ -705,8 +1145,8 @@ private ScheduledExecutorService executor(String name, List events) thro retirement.completeExceptionally(new IllegalStateException("final flush failed")); platform.afterStorageExecutorShutdown().get(0).action().run(); - verify(sqlite).closeConnection(); - verify(plugin).closePendingNativeUserStorageOwners(); + verify(sqlite, timeout(2_000)).closeConnection(); + verify(plugin, timeout(2_000)).closePendingNativeUserStorageOwners(); } @Test void bukkitAdapterClosesMysqlOwnerInstalledWhileSharedReplacementCompletes() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/FinalPublicationSequenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/FinalPublicationSequenceTest.java new file mode 100644 index 0000000000..dec44c62c8 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/FinalPublicationSequenceTest.java @@ -0,0 +1,93 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.concurrent.*; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.permissions.PermissionHandler; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.data.ServerData; + +class FinalPublicationSequenceTest { + @Test void asynchronousOwnersPublishEachFinalSnapshotBeforeStartingTheNext() throws Exception { + verifySequence(false); + } + + @Test void failedInventoryPublicationStillOrdersIndependentFinalCleanup() throws Exception { + verifySequence(true); + } + + private void verifySequence(boolean failInventory) throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isLoadServerData()).thenReturn(true); + FullInventoryHandler inventory = mock(FullInventoryHandler.class); + PermissionHandler permissions = mock(PermissionHandler.class); + ServerData owner = mock(ServerData.class); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + when(plugin.getPermissionHandler()).thenReturn(permissions); + when(plugin.getServerDataFile()).thenReturn(owner); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + var inventorySave = new CompletableFuture(); + var timestampSave = new CompletableFuture(); + var permissionSave = new CompletableFuture(); + List started = new CopyOnWriteArrayList<>(); + when(inventory.shutdownAsync(any())).thenAnswer(call -> { + call.getArgument(0, Executor.class).execute(() -> started.add("inventory")); + return inventorySave; + }); + when(owner.mutateFinalData(any())).thenAnswer(call -> { + started.add("timestamp"); + return timestampSave; + }); + when(permissions.shutDownAsync(any())).thenAnswer(call -> { + call.getArgument(0, Executor.class).execute(() -> started.add("permissions")); + return permissionSave; + }); + AtomicReference> finalFiles = new AtomicReference<>(); + doAnswer(call -> { finalFiles.set(call.getArgument(0)); return null; }) + .when(plugin).onShutdownComplete(any(CompletionStage.class)); + var platform = new BukkitRuntimePlatform(plugin); + var workerField = BukkitRuntimePlatform.class.getDeclaredField("persistenceWorker"); + workerField.setAccessible(true); + var worker = (ThreadPoolExecutor) workerField.get(platform); + try { + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("full inventory handler") + || cleanup.name().equals("server data timestamp")) + .forEach(cleanup -> cleanup.action().run()); + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("permission handler")) + .forEach(cleanup -> cleanup.action().run()); + worker.submit(() -> { }).get(2, TimeUnit.SECONDS); + assertEquals(List.of("inventory"), started, "returning from the worker task is not publication"); + if (failInventory) inventorySave.completeExceptionally(new IllegalStateException("synthetic failure")); + else inventorySave.complete(null); + worker.submit(() -> { }).get(2, TimeUnit.SECONDS); + assertEquals(List.of("inventory", "timestamp"), started); + platform.afterExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("shutdown persistence worker") + || cleanup.name().equals("plugin shutdown completion hook")) + .forEach(cleanup -> cleanup.action().run()); + timestampSave.complete(null); + // Completion remains observable after submission closes; the final + // permission task must still be accepted by the retained worker. + permissionSave.complete(null); + if (failInventory) assertThrows(ExecutionException.class, + () -> finalFiles.get().toCompletableFuture().get(2, TimeUnit.SECONDS)); + else finalFiles.get().toCompletableFuture().get(2, TimeUnit.SECONDS); + assertTrue(worker.awaitTermination(2, TimeUnit.SECONDS)); + assertEquals(List.of("inventory", "timestamp", "permissions"), started); + } finally { + inventorySave.complete(null); timestampSave.complete(null); permissionSave.complete(null); + worker.shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/NativeServerDataAdmissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/NativeServerDataAdmissionTest.java new file mode 100644 index 0000000000..fdbbd361d7 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/NativeServerDataAdmissionTest.java @@ -0,0 +1,566 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.Server; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; +import org.mockito.MockedConstruction; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.permissions.PermissionHandler; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.rewards.RewardEligibility; +import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.command.CommandLoader; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.data.ServerData; +import com.bencodez.advancedcore.lifecycle.AdvancedCoreLifecycle; +import com.bencodez.simpleapi.dialog.UniDialogService; +import com.bencodez.simpleapi.messages.actionbar.ActionBar; +import com.bencodez.simpleapi.messages.actionbar.ActionBarManager; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.valuerequest.ValueRequest; + +/** Exercises real enable/disable admission and native publication; game integrations are external fixtures. */ +@ResourceLock(Resources.SYSTEM_PROPERTIES) +class NativeServerDataAdmissionTest { + @TempDir Path directory; + private Properties original; + private final List workers = new ArrayList<>(); + private MockedStatic lifecycle; + private MockedStatic actionBar; + private MockedStatic values; + private MockedStatic commands; + private MockedStatic bukkit; + private MockedConstruction actionBars; + private MockedConstruction dialogs; + + @BeforeEach void isolatePlatformIntegrations() { + original = System.getProperties(); + var isolated = new Properties(); + isolated.putAll(original); + System.setProperties(isolated); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + lifecycle = mockStatic(AdvancedCoreLifecycle.class); + lifecycle.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(any(AdvancedCorePlugin.class))) + .thenAnswer(call -> { + var group = AdvancedCoreRuntime.createExecutors(); + workers.addAll(List.of(group.timer(), group.loginTimer(), group.inventoryTimer())); + return new AdvancedCoreLifecycle.RuntimeExecutors(mock(BukkitScheduler.class), group.timer(), + group.loginTimer(), group.inventoryTimer()); + }); + actionBar = mockStatic(ActionBar.class); + values = mockStatic(ValueRequest.class); + commands = mockStatic(CommandLoader.class); + actionBars = mockConstruction(ActionBarManager.class); + dialogs = mockConstruction(UniDialogService.class); + } + + @AfterEach void restorePlatform() throws Exception { + try { + for (ScheduledExecutorService worker : workers) worker.shutdownNow(); + for (ScheduledExecutorService worker : workers) assertTrue(worker.awaitTermination(2, TimeUnit.SECONDS)); + } finally { + dialogs.close(); actionBars.close(); commands.close(); values.close(); actionBar.close(); lifecycle.close(); + bukkit.close(); + var singleton = AdvancedCorePlugin.class.getDeclaredField("javaPlugin"); + singleton.setAccessible(true); + singleton.set(null, null); + System.setProperties(original); + } + } + + @Test void pendingNativeSaveRejectsSuccessorBeforeItsEarlyReadAndAllowsRetryAfterPublication() throws Exception { + Files.writeString(directory.resolve("ServerData.yml"), "LastUpdated: 1\nPrevDay: 20\n"); + Fixture predecessor = fixture(); + BlockingServerData owner = new BlockingServerData(predecessor.plugin); + owner.setup(); + when(predecessor.plugin.getServerDataFile()).thenReturn(owner); + predecessor.plugin.onEnable(); + owner.block = true; + predecessor.plugin.onDisable(); + assertTrue(owner.saving.await(2, TimeUnit.SECONDS)); + assertFalse(predecessor.finalFiles.get().toCompletableFuture().isDone()); + Fixture successor = fixture(); + ServerData successorData = new ServerData(successor.plugin); + when(successor.plugin.getServerDataFile()).thenReturn(successorData); + AtomicInteger reads = new AtomicInteger(); + doAnswer(call -> { successorData.setup(); reads.incrementAndGet(); return null; }).when(successor.plugin).onPreLoad(); + try { + assertTimeoutPreemptively(Duration.ofSeconds(1), () -> + assertThrows(IllegalStateException.class, successor.plugin::onEnable)); + assertEquals(0, reads.get()); + verify(successor.plugin, never()).onPreLoad(); + verify(successor.plugin, never()).loadHook(); + successor.plugin.onDisable(); + verify(successor.plugin, never()).onBeforeStorageShutdown(); + predecessor.plugin.onDisable(); + assertEquals(1, owner.saves.get(), "duplicate disable cannot admit another stale write"); + } finally { owner.release.countDown(); } + predecessor.awaitRelease(); + assertFalse(predecessor.plugin.hasServerDataOwnership()); + assertTrue(YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()) + .getLong("LastUpdated") > 1); + successor.plugin.onEnable(); + assertEquals(1, reads.get()); + assertTrue(successorData.getLastUpdated() > 1, "native startup reads the confirmed predecessor snapshot"); + assertEquals(20, successorData.getPrevDay()); + predecessor.plugin.onDisable(); + assertEquals(1, owner.saves.get(), "released predecessors cannot write over the active successor"); + assertTrue(successor.plugin.hasServerDataOwnership()); + successor.plugin.onDisable(); + successor.awaitRelease(); + verify(predecessor.plugin, times(1)).onUnLoad(); + } + + @Test void failedStartupWithoutAnAdmittedNativeWriterReleasesAndRetiresItsCreatedExecutors() throws Exception { + Fixture failed = fixture(); + var failure = new IllegalStateException("early consumer startup failed"); + doThrow(failure).when(failed.plugin).onPreLoad(); + assertSame(failure, assertThrows(IllegalStateException.class, failed.plugin::onEnable)); + failed.awaitRelease(); + assertFalse(failed.plugin.hasServerDataOwnership()); + assertFalse(Files.exists(directory.resolve("ServerData.yml"))); + verify(failed.plugin, never()).loadHook(); + assertTrue(workers.stream().allMatch(ScheduledExecutorService::isTerminated)); + Fixture successor = fixture(); + successor.plugin.onEnable(); + successor.plugin.onDisable(); + successor.awaitRelease(); + } + + @Test void disabledUninitializedServerDataSkipsAllFinalWritesAndAllowsSameJvmReenable() throws Exception { + Fixture fixture = fixture(); + when(fixture.plugin.isLoadServerData()).thenReturn(false); + ServerData owner = spy(new ServerData(fixture.plugin)); + when(fixture.plugin.getServerDataFile()).thenReturn(owner); + for (int generation = 0; generation < 2; generation++) { + FullInventoryHandler inventory = new FullInventoryHandler(fixture.plugin); + PermissionHandler permissions = new PermissionHandler(fixture.plugin); + workers.add(inventory.getTimer()); + workers.add(permissions.getTimer()); + when(fixture.plugin.getFullInventoryHandler()).thenReturn(inventory); + when(fixture.plugin.getPermissionHandler()).thenReturn(permissions); + fixture.plugin.onEnable(); + fixture.plugin.onDisable(); + fixture.finalFiles.get().toCompletableFuture().get(2, TimeUnit.SECONDS); + fixture.awaitRelease(); + assertTrue(inventory.getTimer().isTerminated()); + assertTrue(permissions.getTimer().isTerminated()); + assertFalse(fixture.plugin.hasServerDataOwnership()); + } + verify(owner, never()).mutateFinalData(any()); + assertNull(owner.getData()); + assertFalse(Files.exists(directory.resolve("ServerData.yml"))); + verify(fixture.plugin, times(2)).onUnLoad(); + } + + @Test void enabledUninitializedServerDataStillFailsAndRetainsNativeAdmission() throws Exception { + Fixture fixture = fixture(); + ServerData owner = new ServerData(fixture.plugin); + when(fixture.plugin.getServerDataFile()).thenReturn(owner); + fixture.plugin.onEnable(); + fixture.plugin.onDisable(); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> fixture.finalFiles.get().toCompletableFuture().get(2, TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> fixture.releaseFence.get().get(2, TimeUnit.SECONDS)); + assertTrue(fixture.plugin.hasServerDataOwnership(), "enabled but failed persistence cannot release ownership"); + } + + @Test void initializedServerDataStillPublishesWhenLoadFlagChangesBeforeShutdown() throws Exception { + Fixture fixture = fixture(); + ServerData owner = new ServerData(fixture.plugin); + owner.setup(); + when(fixture.plugin.getServerDataFile()).thenReturn(owner); + fixture.plugin.onEnable(); + when(fixture.plugin.isLoadServerData()).thenReturn(false); + fixture.plugin.onDisable(); + fixture.awaitRelease(); + assertTrue(YamlConfiguration.loadConfiguration(directory.resolve("ServerData.yml").toFile()) + .getLong("LastUpdated") > 0); + } + + @Test void successfulShutdownAllowsSameInstanceReenableAndDuplicateDisableStaysInert() throws Exception { + Fixture plugin = fixture(); + ServerData owner = new ServerData(plugin.plugin); + owner.setup(); + when(plugin.plugin.getServerDataFile()).thenReturn(owner); + for (int generation = 0; generation < 2; generation++) { + plugin.plugin.onEnable(); + assertTrue(plugin.plugin.hasServerDataOwnership()); + plugin.plugin.onDisable(); + plugin.awaitRelease(); + assertFalse(plugin.plugin.hasServerDataOwnership()); + plugin.plugin.onDisable(); + } + verify(plugin.plugin, times(2)).onPreLoad(); + verify(plugin.plugin, times(2)).onBeforeStorageShutdown(); + verify(plugin.plugin, times(2)).onUnLoad(); + verify(plugin.plugin, times(2)).onShutdownComplete(any(CompletionStage.class)); + assertEquals(6, workers.size(), "reenable keeps the established fresh shared executor creation"); + } + + @Test void shutdownAdmissionDoesNotWaitForConsumerReplayMonitor() throws Exception { + Fixture fixture = fixture(); + CountDownLatch monitorHeld = new CountDownLatch(1); + CountDownLatch releaseMonitor = new CountDownLatch(1); + Thread holder = new Thread(() -> { + synchronized (fixture.plugin) { + monitorHeld.countDown(); + try { + assertTrue(releaseMonitor.await(4, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } + } + }, "replay-monitor-holder"); + holder.start(); + assertTrue(monitorHeld.await(1, TimeUnit.SECONDS)); + Thread watchdog = new Thread(() -> { + try { + Thread.sleep(2_000); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + releaseMonitor.countDown(); + } + }, "shutdown-admission-watchdog"); + watchdog.start(); + try { + fixture.plugin.onDisable(); + assertEquals(1, releaseMonitor.getCount(), + "shutdown admission must not wait for a replay/checkpoint monitor"); + } finally { + releaseMonitor.countDown(); + watchdog.interrupt(); + watchdog.join(2_000); + holder.join(2_000); + } + assertFalse(holder.isAlive(), "monitor holder must be released after the test"); + } + + @Test void admittedPreparationRetainsAdmissionUntilItsActualSuccess() throws Exception { + Fixture predecessor = fixture(); + var preparation = new CompletableFuture(); + when(predecessor.plugin.onBeforeStorageShutdown()).thenReturn(preparation); + predecessor.plugin.onEnable(); + predecessor.plugin.onDisable(); + assertFalse(predecessor.releaseFence.get().isDone()); + Fixture successor = fixture(); + assertThrows(IllegalStateException.class, successor.plugin::onEnable); + preparation.complete(null); + predecessor.awaitRelease(); + successor.plugin.onEnable(); + successor.plugin.onDisable(); + successor.awaitRelease(); + } + + @Test void failedOrCancelledPreparationRetainsLeaseEvenWhenAnOlderNativeSaveLaterFinishes() throws Exception { + for (boolean cancelled : new boolean[] {false, true}) { + Path dataDirectory = directory.resolve(cancelled ? "cancelled" : "failed"); + Files.createDirectories(dataDirectory); + Fixture predecessor = fixture(dataDirectory); + var preparation = new CompletableFuture(); + when(predecessor.plugin.onBeforeStorageShutdown()).thenReturn(preparation); + BlockingServerData owner = new BlockingServerData(predecessor.plugin); + owner.setup(); owner.block = true; + when(predecessor.plugin.getServerDataFile()).thenReturn(owner); + predecessor.plugin.onEnable(); + predecessor.plugin.onDisable(); + assertTrue(owner.saving.await(2, TimeUnit.SECONDS)); + if (cancelled) preparation.cancel(false); + else preparation.completeExceptionally(new IllegalStateException("preparation failed")); + Fixture successor = fixture(dataDirectory); + try { + assertThrows(IllegalStateException.class, successor.plugin::onEnable); + assertFalse(predecessor.finalFiles.get().toCompletableFuture().isDone()); + } finally { owner.release.countDown(); } + assertThrows(java.util.concurrent.ExecutionException.class, + () -> predecessor.releaseFence.get().get(2, TimeUnit.SECONDS)); + predecessor.finalFiles.get().toCompletableFuture().get(2, TimeUnit.SECONDS); + assertTrue(predecessor.plugin.hasServerDataOwnership(), "failure settlement is not proven owner drain"); + assertThrows(IllegalStateException.class, successor.plugin::onEnable, + "a later successful save cannot turn failed preparation into safe admission"); + } + } + + @Test void cancelledPublicationDoesNotReleaseWhileItsNativeWriterIsStillExecuting() throws Exception { + assertNativeWriterRetirement(false); + } + + @Test void successfulPublicationStillWaitsForActualNativeExecutorTermination() throws Exception { + assertNativeWriterRetirement(true); + } + + private void assertNativeWriterRetirement(boolean success) throws Exception { + Fixture predecessor = fixture(); + var publication = new CompletableFuture(); + CountDownLatch writing = new CountDownLatch(1); + CountDownLatch releaseWriter = new CountDownLatch(1); + ScheduledExecutorService nativeWriter = Executors.newSingleThreadScheduledExecutor( + AdvancedCoreRuntime.daemonThreads("native-owner-fixture")); + workers.add(nativeWriter); + TimeChecker checker = mock(TimeChecker.class); + when(checker.getTimer()).thenReturn(nativeWriter); + when(checker.beginShutdown()).thenReturn(CompletableFuture.completedFuture(null)); + when(predecessor.plugin.getTimeChecker()).thenReturn(checker); + ServerData owner = mock(ServerData.class); + when(owner.mutateFinalData(any())).thenReturn(publication); + when(predecessor.plugin.getServerDataFile()).thenReturn(owner); + nativeWriter.execute(() -> { + writing.countDown(); + while (releaseWriter.getCount() != 0) { + try { releaseWriter.await(); } + catch (InterruptedException ignored) { /* Interrupt is deliberately not completion. */ } + } + }); + assertTrue(writing.await(2, TimeUnit.SECONDS)); + predecessor.plugin.onEnable(); + try { + // The injected clock expires only AdvancedCore's owned caller waits. + new AdvancedCoreRuntime(new BukkitRuntimePlatform(predecessor.plugin), + () -> TimeUnit.SECONDS.toNanos(15)).shutdown(0L); + if (success) publication.complete(null); + else publication.cancel(false); + assertFalse(nativeWriter.isTerminated()); + assertTrue(predecessor.plugin.hasServerDataOwnership()); + assertThrows(IllegalStateException.class, fixture().plugin::onEnable); + if (success) assertFalse(predecessor.releaseFence.get().isDone()); + else assertThrows(java.util.concurrent.ExecutionException.class, + () -> predecessor.releaseFence.get().get(2, TimeUnit.SECONDS)); + } finally { releaseWriter.countDown(); } + assertTrue(nativeWriter.awaitTermination(2, TimeUnit.SECONDS)); + if (success) { + predecessor.awaitRelease(); + Fixture successor = fixture(); + successor.plugin.onEnable(); + successor.plugin.onDisable(); + successor.awaitRelease(); + } else { + assertTrue(predecessor.plugin.hasServerDataOwnership(), "a cancelled stage never proves native drain"); + assertThrows(IllegalStateException.class, fixture().plugin::onEnable); + } + } + + @Test void directLoadHookRejectsBeforeConstructingANativeFileOwner() throws Exception { + Fixture predecessor = fixture(); + predecessor.plugin.onEnable(); + Fixture successor = fixture(); + doCallRealMethod().when(successor.plugin).loadHook(); + try (var nativeFiles = mockConstruction(ServerData.class)) { + assertThrows(IllegalStateException.class, successor.plugin::loadHook); + assertTrue(nativeFiles.constructed().isEmpty()); + } finally { + predecessor.plugin.onDisable(); + predecessor.awaitRelease(); + } + } + + @Test void synchronousFinalSaveAdmissionFailureCannotAppearAsSuccessfulPublication() throws Exception { + Fixture predecessor = fixture(); + PermissionHandler permissions = mock(PermissionHandler.class); + when(permissions.shutDownAsync(any())).thenThrow(new IllegalStateException("snapshot admission failed")); + when(predecessor.plugin.getPermissionHandler()).thenReturn(permissions); + predecessor.plugin.onEnable(); + predecessor.plugin.onDisable(); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> predecessor.releaseFence.get().get(2, TimeUnit.SECONDS)); + assertTrue(predecessor.finalFiles.get().toCompletableFuture().isCompletedExceptionally()); + assertTrue(predecessor.plugin.hasServerDataOwnership()); + assertThrows(IllegalStateException.class, fixture().plugin::onEnable); + } + + @Test void cancellingTheConsumersFinalFilesObservationDoesNotCancelNativePublicationOrAdmissionRelease() + throws Exception { + Fixture predecessor = fixture(); + var publication = new CompletableFuture(); + ServerData owner = mock(ServerData.class); + when(owner.mutateFinalData(any())).thenReturn(publication); + when(predecessor.plugin.getServerDataFile()).thenReturn(owner); + predecessor.plugin.onEnable(); + predecessor.plugin.onDisable(); + assertTrue(predecessor.finalFiles.get().toCompletableFuture().cancel(false)); + assertFalse(publication.isDone()); + publication.complete(null); + predecessor.awaitRelease(); + Fixture successor = fixture(); + successor.plugin.onEnable(); + successor.plugin.onDisable(); + successor.awaitRelease(); + } + + @Test void failedUnloadOrCompletionHookCannotReleaseUncertainAdmission() throws Exception { + for (boolean completionHook : new boolean[] {false, true}) { + Fixture predecessor = fixture(directory.resolve(completionHook ? "completion" : "unload")); + var failure = new IllegalStateException("consumer cleanup failed"); + if (completionHook) doThrow(failure).when(predecessor.plugin).onShutdownComplete(any(CompletionStage.class)); + else doThrow(failure).when(predecessor.plugin).onUnLoad(); + predecessor.plugin.onEnable(); + predecessor.plugin.onDisable(); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> predecessor.releaseFence.get().get(2, TimeUnit.SECONDS)); + assertTrue(predecessor.plugin.hasServerDataOwnership()); + assertThrows(IllegalStateException.class, fixture(predecessor.plugin.getDataFolder().toPath()).plugin::onEnable); + } + } + + @Test void storageRetirementAdmissionIsVisibleBeforeWorkerAcceptReturns() throws Exception { + Fixture fixture = fixture(); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + when(fixture.plugin.isLoadUserData()).thenReturn(true); + when(fixture.plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(manager.getTimer()).thenReturn(worker); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(fixture.plugin); + AtomicReference> observed = new AtomicReference<>(); + doAnswer(invocation -> { + observed.set(platform.admittedStorageRetirementCompletion()); + return null; + }).when(worker).execute(any(Runnable.class)); + + CompletionStage returned = invokeDeferredRetirement(platform); + + assertSame(returned, observed.get(), + "the watchdog-visible admission must be reserved before execute returns"); + assertSame(returned, platform.admittedStorageRetirementCompletion()); + } + + @Test void rejectedStorageRetirementAdmissionRemainsFailedAndObservable() throws Exception { + Fixture fixture = fixture(); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + when(fixture.plugin.isLoadUserData()).thenReturn(true); + when(fixture.plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(manager.getTimer()).thenReturn(worker); + doThrow(new RejectedExecutionException("retirement worker stopped")).when(worker).execute(any(Runnable.class)); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(fixture.plugin); + + CompletionStage returned = invokeDeferredRetirement(platform); + + assertSame(returned, platform.admittedStorageRetirementCompletion()); + assertTrue(returned.toCompletableFuture().isCompletedExceptionally(), + "worker rejection must not leave an apparently pending successful admission"); + } + + private static CompletionStage invokeDeferredRetirement(BukkitRuntimePlatform platform) + throws Exception { + var method = BukkitRuntimePlatform.class.getDeclaredMethod("deferUserStorageRetirement", Throwable.class); + method.setAccessible(true); + return (CompletionStage) method.invoke(platform, new Object[] { null }); + } + + private Fixture fixture() { return fixture(directory); } + + private Fixture fixture(Path dataDirectory) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + initializeLifecycleLock(plugin); + when(plugin.isLoadServerData()).thenReturn(true); + Server server = mock(Server.class); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + when(plugin.getName()).thenReturn("native-admission-fixture"); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(mock(org.bukkit.scheduler.BukkitScheduler.class)); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + RewardHandler rewards = mock(RewardHandler.class); + when(rewards.getRewardEligibility()).thenReturn(mock(RewardEligibility.class)); + when(plugin.getRewardHandler()).thenReturn(rewards); + doCallRealMethod().when(plugin).onEnable(); + doCallRealMethod().when(plugin).onDisable(); + doCallRealMethod().when(plugin).hasServerDataOwnership(); + doCallRealMethod().when(plugin).onBeforeStorageShutdown(); + doCallRealMethod().when(plugin).getTimer(); + doCallRealMethod().when(plugin).getLoginTimer(); + doCallRealMethod().when(plugin).getInventoryTimer(); + var finalFiles = new AtomicReference>(); + doAnswer(call -> { finalFiles.set(call.getArgument(0)); return null; }) + .when(plugin).onShutdownComplete(any(CompletionStage.class)); + var releaseFence = new AtomicReference>(); + doAnswer(call -> { + CompletionStage completion = call.getArgument(0); + var observed = new CompletableFuture(); + releaseFence.set(observed); + call.callRealMethod(); + completion.whenComplete((ignored, failure) -> { + if (failure == null) observed.complete(null); + else observed.completeExceptionally(failure); + }); + return null; + }).when(plugin).releaseServerDataOwnershipAfter(any(CompletionStage.class)); + return new Fixture(plugin, finalFiles, releaseFence); + } + + private static void initializeLifecycleLock(AdvancedCorePlugin plugin) { + try { + var lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lock.setAccessible(true); + lock.set(plugin, new Object()); + } catch (ReflectiveOperationException failure) { + throw new AssertionError("Unable to initialize lifecycle test fixture", failure); + } + } + + private record Fixture(AdvancedCorePlugin plugin, AtomicReference> finalFiles, + AtomicReference> releaseFence) { + void awaitRelease() throws Exception { + releaseFence.get().get(2, TimeUnit.SECONDS); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (plugin.hasServerDataOwnership()) { + if (System.nanoTime() >= deadline) fail("successful native drain did not release admission"); + Thread.sleep(1); + } + } + } + + private static final class BlockingServerData extends ServerData { + private final CountDownLatch saving = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final AtomicInteger saves = new AtomicInteger(); + private volatile boolean block; + BlockingServerData(AdvancedCorePlugin plugin) { super(plugin); } + @Override public synchronized void saveDataChecked() { + saves.incrementAndGet(); + if (block) { + saving.countDown(); + try { assertTrue(release.await(10, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + } + super.saveDataChecked(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataOwnershipTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataOwnershipTest.java new file mode 100644 index 0000000000..e5f0b7c955 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataOwnershipTest.java @@ -0,0 +1,133 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; + +import java.lang.reflect.InvocationTargetException; +import java.nio.file.Path; +import java.util.Properties; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; + +import com.bencodez.advancedcore.lifecycle.ServerDataOwnership; + +@ResourceLock(Resources.SYSTEM_PROPERTIES) +class ServerDataOwnershipTest { + private static final String PREFIX = "AdvancedCore:ServerData-ownership:v1:"; + @TempDir Path directory; + private Properties original; + private Properties properties; + + @BeforeEach void isolateProperties() { + original = System.getProperties(); + properties = new Properties(); + properties.putAll(original); + System.setProperties(properties); + } + + @AfterEach void restoreProperties() { System.setProperties(original); } + + @Test void pendingCompletionRejectsTheSameNormalizedPathAndSuccessAllowsRetry() { + Path file = directory.resolve("ServerData.yml"); + var owner = ServerDataOwnership.acquire(file); + var pending = new CompletableFuture(); + owner.releaseAfter(pending); + assertFalse(owner.isReleased()); + assertThrows(IllegalStateException.class, + () -> ServerDataOwnership.acquire(directory.resolve("unused/../ServerData.yml"))); + pending.complete(null); + assertTrue(owner.isReleased()); + var successor = ServerDataOwnership.acquire(file); + // A duplicate predecessor callback must never remove the successor token. + owner.releaseAfter(CompletableFuture.completedFuture(null)); + assertThrows(IllegalStateException.class, () -> ServerDataOwnership.acquire(file)); + successor.releaseAfter(CompletableFuture.completedFuture(null)); + assertTrue(successor.isReleased()); + } + + @Test void failureAndCancellationRetainAdmissionEvenAfterALaterSuccessSignal() { + for (boolean cancelled : new boolean[] {false, true}) { + Path file = directory.resolve(cancelled ? "cancelled.yml" : "failed.yml"); + var owner = ServerDataOwnership.acquire(file); + var pending = new CompletableFuture(); + owner.releaseAfter(pending); + if (cancelled) pending.cancel(false); + else pending.completeExceptionally(new IllegalStateException("publication failed")); + owner.releaseAfter(CompletableFuture.completedFuture(null)); + assertFalse(owner.isReleased()); + assertThrows(IllegalStateException.class, () -> ServerDataOwnership.acquire(file)); + } + } + + @Test void releaseUsesItsOriginatingPropertiesObject() { + Path file = directory.resolve("ServerData.yml"); + var owner = ServerDataOwnership.acquire(file); + String key = properties.stringPropertyNames().stream().filter(name -> name.startsWith(PREFIX)) + .findFirst().orElseThrow(); + Properties replacement = new Properties(); + replacement.putAll(properties); + System.setProperties(replacement); + owner.releaseAfter(CompletableFuture.completedFuture(null)); + assertTrue(owner.isReleased()); + assertFalse(properties.containsKey(key)); + assertTrue(replacement.containsKey(key), "release must not mutate another Properties generation"); + } + + @Test void independentClassloadersShareOnlyStringAdmissionTokens() throws Exception { + Class predecessorType = isolatedOwnershipType(); + Class successorType = isolatedOwnershipType(); + assertNotSame(predecessorType.getClassLoader(), successorType.getClassLoader()); + Path file = directory.resolve("ServerData.yml"); + Object predecessor = predecessorType.getMethod("acquire", Path.class).invoke(null, file); + var publication = new CompletableFuture(); + predecessorType.getMethod("releaseAfter", CompletionStage.class).invoke(predecessor, publication); + InvocationTargetException denial = assertThrows(InvocationTargetException.class, + () -> successorType.getMethod("acquire", Path.class).invoke(null, file)); + assertInstanceOf(IllegalStateException.class, denial.getCause()); + properties.forEach((key, value) -> { + if (key instanceof String name && name.startsWith(PREFIX)) assertInstanceOf(String.class, value); + }); + publication.complete(null); + Object successor = successorType.getMethod("acquire", Path.class).invoke(null, file); + successorType.getMethod("releaseAfter", CompletionStage.class).invoke(successor, + CompletableFuture.completedFuture(null)); + } + + @Test void capacityIsBoundedAndAnExistingOwnerIsNeverEvicted() { + for (int index = 0; index < 256; index++) properties.setProperty(PREFIX + "fixture-" + index, "opaque"); + assertThrows(IllegalStateException.class, () -> ServerDataOwnership.acquire(directory.resolve("new.yml"))); + properties.remove(PREFIX + "fixture-255"); + var owner = ServerDataOwnership.acquire(directory.resolve("new.yml")); + owner.releaseAfter(CompletableFuture.completedFuture(null)); + assertEquals("opaque", properties.getProperty(PREFIX + "fixture-0")); + } + + @Test void admissionDoesNotCreateTheDataDirectory() { + Path file = directory.resolve("absent/ServerData.yml"); + var owner = ServerDataOwnership.acquire(file); + assertFalse(java.nio.file.Files.exists(file.getParent())); + owner.releaseAfter(CompletableFuture.completedFuture(null)); + assertThrows(IllegalArgumentException.class, () -> ServerDataOwnership.acquire(Path.of("a".repeat(8193)))); + } + + private static Class isolatedOwnershipType() throws Exception { + String name = ServerDataOwnership.class.getName(); + byte[] bytes; + try (var input = ServerDataOwnership.class.getResourceAsStream("ServerDataOwnership.class")) { + bytes = java.util.Objects.requireNonNull(input).readAllBytes(); + } + ClassLoader loader = new ClassLoader(ClassLoader.getPlatformClassLoader()) { + @Override protected Class findClass(String requested) throws ClassNotFoundException { + if (!requested.equals(name)) throw new ClassNotFoundException(requested); + return defineClass(requested, bytes, 0, bytes.length); + } + }; + return loader.loadClass(name); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataProcessHookTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataProcessHookTest.java new file mode 100644 index 0000000000..3321ea23cd --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataProcessHookTest.java @@ -0,0 +1,67 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.api.parallel.Resources; +import com.bencodez.advancedcore.lifecycle.ServerDataOwnership; + +@ResourceLock(Resources.SYSTEM_PROPERTIES) +class ServerDataProcessHookTest { + @TempDir Path directory; + + @Test void earlyRegistrationAndConfirmedReleaseRemoveTheExactHook() { + Runtime runtime = mock(Runtime.class); + var hook = org.mockito.ArgumentCaptor.forClass(Thread.class); + try (var scoped = mockStatic(Runtime.class)) { + scoped.when(Runtime::getRuntime).thenReturn(runtime); + var owner = ServerDataOwnership.acquire(directory.resolve("ServerData.yml")); + verify(runtime).addShutdownHook(hook.capture()); + var completion = new CompletableFuture(); owner.releaseAfter(completion); + assertFalse(owner.isReleased()); + completion.complete(null); + assertTrue(owner.isReleased()); + verify(runtime, times(1)).removeShutdownHook(hook.getValue()); + } + } + + @Test void repeatedDisableDoesNotRenewTheSpentDeadline() throws Exception { + Runtime runtime = mock(Runtime.class); + var removed = new CountDownLatch(1); + when(runtime.removeShutdownHook(any())).thenAnswer(call -> { removed.countDown(); return true; }); + try (var scoped = mockStatic(Runtime.class)) { + scoped.when(Runtime::getRuntime).thenReturn(runtime); + var owner = ServerDataOwnership.acquire(directory.resolve("ServerData.yml")); + try { + owner.beginShutdown(System.nanoTime() - TimeUnit.SECONDS.toNanos(15) + TimeUnit.MILLISECONDS.toNanos(20)); + owner.beginShutdown(System.nanoTime()); + assertTrue(removed.await(2, TimeUnit.SECONDS)); + assertFalse(owner.isReleased(), "deadline expiry is not confirmed publication"); + } finally { owner.releaseAfter(CompletableFuture.completedFuture(null)); } + verify(runtime, times(1)).addShutdownHook(any()); + verify(runtime, times(1)).removeShutdownHook(any()); + } + } + + @Test void failedRegistrationRollsBackOnlyItsUnpublishedAdmission() { + Runtime runtime = mock(Runtime.class); + doThrow(new IllegalStateException("shutdown already started")).when(runtime).addShutdownHook(any()); + try (var scoped = mockStatic(Runtime.class)) { + scoped.when(Runtime::getRuntime).thenReturn(runtime); + assertThrows(IllegalStateException.class, + () -> ServerDataOwnership.acquire(directory.resolve("ServerData.yml"))); + doNothing().when(runtime).addShutdownHook(any()); + var recovered = ServerDataOwnership.acquire(directory.resolve("ServerData.yml")); + recovered.releaseAfter(CompletableFuture.completedFuture(null)); + assertTrue(recovered.isReleased()); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataPublicationOrderingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataPublicationOrderingTest.java new file mode 100644 index 0000000000..c8742ed3c3 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ServerDataPublicationOrderingTest.java @@ -0,0 +1,852 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.Server; +import org.bukkit.configuration.file.*; +import org.bukkit.configuration.serialization.ConfigurationSerialization; +import org.bukkit.configuration.serialization.SerializableAs; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.data.ServerData; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +/** Exercises native publication against an already serialized ordinary snapshot. */ +class ServerDataPublicationOrderingTest { + @TempDir File dataFolder; + + @Test void ordinaryCallbackPublishesLiveProjectionBeforeBlockedNativeDiskIo() throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + BarrierServerData data = new BarrierServerData(plugin); + data.install(); + FileConfiguration live = data.getData(); + live.set("VoteParty.Total", 7); + var display = new java.util.concurrent.atomic.AtomicInteger(7); + var finished = new CompletableFuture(); + Thread writer = new Thread(() -> { + try { + data.mutateOrdinaryData(yaml -> { + assertSame(live, yaml); + assertTrue(Thread.holdsLock(data.publicationMonitor())); + yaml.set("VoteParty.Total", 17); + display.set(yaml.getInt("VoteParty.Total")); + }); + finished.complete(null); + } catch (Throwable failure) { finished.completeExceptionally(failure); } + }, "ordinary-live-projection"); + try { + writer.start(); + assertTrue(data.ordinaryWriteStarted.await(2, TimeUnit.SECONDS)); + assertFalse(finished.isDone(), "disk I/O is still held"); + assertEquals(17, display.get(), "display must not wait for disk I/O"); + assertSame(live, data.getData()); + } finally { + data.releaseOrdinaryWrite.countDown(); + writer.join(2000); + if (writer.isAlive()) writer.interrupt(); + } + assertFalse(writer.isAlive()); + finished.get(2, TimeUnit.SECONDS); + assertEquals(17, YamlConfiguration.loadConfiguration(data.getdFile()).getInt("VoteParty.Total")); + } + } + + @Test void ordinaryCallbackFailurePreservesLiveIdentityWithoutSavingOrClaimingRollback() throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + LegacySavingOwner data = legacySavingOwner(); + FileConfiguration live = data.getData(); + var failure = new IllegalArgumentException("synthetic projection failure"); + assertSame(failure, assertThrows(IllegalArgumentException.class, () -> data.mutateOrdinaryData(yaml -> { + assertSame(live, yaml); + yaml.set("VoteParty.Total", 17); + throw failure; + }))); + assertEquals(0, data.saves); + assertSame(live, data.getData()); + assertEquals(17, live.getInt("VoteParty.Total")); + assertFalse(data.getdFile().exists()); + } + } + + @Test void ordinaryCallbackRetainsTheCustomOwnersSaveFailure() throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + LegacySavingOwner data = legacySavingOwner(); + data.saveFailure = new IllegalStateException("synthetic owner failure"); + var display = new java.util.concurrent.atomic.AtomicInteger(); + assertSame(data.saveFailure, assertThrows(IllegalStateException.class, () -> data.mutateOrdinaryData(yaml -> { + yaml.set("VoteParty.Total", 17); + display.set(yaml.getInt("VoteParty.Total")); + }))); + assertEquals(1, data.saves); + assertEquals(17, display.get()); + assertSame(data.getData(), data.savedData); + } + } + + @Test void heartbeatCannotOverwriteAcknowledgedFinalPublication() throws Exception { + verifyPublicationOrder(true); + } + + @Test void directNativeSaveCannotOverwriteAcknowledgedFinalPublication() throws Exception { + verifyPublicationOrder(false); + } + + @Test void failedDirectoryForceReconcilesCommittedFieldsWithoutOverwritingConcurrentLiveUpdates() + throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + FailingDirectoryForceServerData data = new FailingDirectoryForceServerData(plugin); + data.setData(new YamlConfiguration()); + data.getData().set("TimedPermissions.example", java.util.List.of("previous")); + data.getData().set("Unrelated.Value", "previous"); + FileConfiguration live = data.getData(); + var result = new CompletableFuture(); + Thread writer = new Thread(() -> data.mutateFinalData(yaml -> { + yaml.set("FullInventory.committed.Time", 456L); + yaml.set("TimedPermissions.example", java.util.List.of("candidate")); + yaml.set("Unrelated.Value", "candidate"); + }).whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); else result.completeExceptionally(failure); + }), "failing-final-server-data-publication"); + try { + writer.start(); + assertTrue(data.forceStarted.await(2, TimeUnit.SECONDS)); + live.set("TimedPermissions.example", java.util.List.of("concurrent")); + live.set("Unrelated.Value", "concurrent"); + } finally { + data.releaseForce.countDown(); + writer.join(2000); + if (writer.isAlive()) writer.interrupt(); + } + assertFalse(writer.isAlive()); + ExecutionException failure = assertThrows(ExecutionException.class, () -> result.get(2, TimeUnit.SECONDS)); + assertInstanceOf(java.io.UncheckedIOException.class, failure.getCause()); + assertSame(data.forceFailure, failure.getCause().getCause()); + assertSame(live, data.getData()); + assertEquals(456L, live.getLong("FullInventory.committed.Time")); + assertEquals(java.util.List.of("concurrent"), live.getStringList("TimedPermissions.example")); + assertEquals("concurrent", live.getString("Unrelated.Value")); + assertEquals("candidate", YamlConfiguration.loadConfiguration(data.getdFile()).getString("Unrelated.Value")); + data.failing = false; + assertQuarantinedAfterUnconfirmedFinalPublication(data); + var restarted = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals(456L, restarted.getLong("FullInventory.committed.Time")); + assertEquals(java.util.List.of("candidate"), restarted.getStringList("TimedPermissions.example")); + assertEquals("candidate", restarted.getString("Unrelated.Value")); + } + } + + @Test void failedDirectoryForceMergesNestedCommittedSiblingsAndPreservesCompetingChildren() throws Exception { + verifyNestedFinalReconciliation(true); + } + + @Test void successfulPublicationMergesNestedCommittedSiblingsAndPreservesCompetingChildren() throws Exception { + verifyNestedFinalReconciliation(false); + } + + @Test void successfulPublicationPreservesTypedMapKeysInsideUnchangedLists() throws Exception { + verifyUnchangedListPayload(true); + } + + @Test void failedDirectoryForcePreservesTypedMapKeysInsideUnchangedLists() throws Exception { + verifyUnchangedListPayload(false); + } + + private void verifyUnchangedListPayload(boolean successful) throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + Files.writeString(dataFolder.toPath().resolve("ServerData.yml"), """ + Rows: + - 1: first + '1': second + Nested: + 2: nested-integer + '2': nested-string + Values: + - 3: list-integer + '3': list-string + - - 4: deepest-integer + '4': deepest-string + Unrelated: previous + """); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + FailingDirectoryForceServerData data = new FailingDirectoryForceServerData(plugin); + data.setup(); + FileConfiguration live = data.getData(); + java.util.List expectedRows = live.getList("Rows"); + assertPreservedRows(live, expectedRows); + data.failAfterBarrier = !successful; + data.releaseForce.countDown(); + var result = data.mutateFinalData(yaml -> yaml.set("Unrelated", "candidate")).toCompletableFuture(); + if (successful) result.get(2, TimeUnit.SECONDS); + else { + CompletionException failure = assertThrows(CompletionException.class, result::join); + assertInstanceOf(java.io.UncheckedIOException.class, failure.getCause()); + assertSame(data.forceFailure, failure.getCause().getCause()); + } + assertSame(live, data.getData()); + assertEquals("candidate", live.getString("Unrelated")); + assertPreservedRows(live, expectedRows); + var published = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals("candidate", published.getString("Unrelated")); + assertPreservedRows(published, expectedRows); + data.failing = false; + if (successful) { data.setLastUpdated(); data.reloadData(); } + else assertQuarantinedAfterUnconfirmedFinalPublication(data); + assertPreservedRows(data.getData(), expectedRows); + assertPreservedRows(YamlConfiguration.loadConfiguration(data.getdFile()), expectedRows); + assertEquals(!successful, result.isCompletedExceptionally()); + } + } + + private static void assertPreservedRows(FileConfiguration yaml, java.util.List expectedRows) { + assertNotNull(expectedRows); + assertEquals(expectedRows, yaml.getList("Rows")); + Map row = (Map) yaml.getList("Rows").get(0); + assertEquals("first", row.get(1)); + assertEquals("second", row.get("1")); + Map nested = (Map) row.get("Nested"); + assertEquals("nested-integer", nested.get(2)); + assertEquals("nested-string", nested.get("2")); + java.util.List values = (java.util.List) nested.get("Values"); + Map listMap = (Map) values.get(0); + assertEquals("list-integer", listMap.get(3)); + assertEquals("list-string", listMap.get("3")); + Map deepest = (Map) ((java.util.List) values.get(1)).get(0); + assertEquals("deepest-integer", deepest.get(4)); + assertEquals("deepest-string", deepest.get("4")); + } + + private void verifyNestedFinalReconciliation(boolean failForce) throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + FailingDirectoryForceServerData data = new FailingDirectoryForceServerData(plugin); + data.failAfterBarrier = failForce; + data.setData(new YamlConfiguration()); + FileConfiguration live = data.getData(); + live.set("TimedPermissions.existing", java.util.List.of("previous")); + live.set("Nested.branch.existing", "previous"); + live.set("Nested.branch.removed", "previous"); + live.set("Nested.branch.competingRemoval", "previous"); + live.set("Nested.branch.competingValue", "previous"); + live.set("Nested.removedTree.unchanged", "previous"); + live.set("Nested.removedTree.competing", "previous"); + live.set("Nested.scalarToSection", "previous"); + live.set("Nested.competingScalarToSection", "previous"); + live.set("Nested.sectionToScalar.child", "previous"); + live.set("Nested.competingSectionToScalar.child", "previous"); + live.set("Nested.concurrentlyRemoved.child", "previous"); + live.set("Nested.concurrentlyReplaced.child", "previous"); + live.set("RawMap", Map.of("existing", "previous", "removed", "previous", + "competing", "previous", "numeric", Map.of(1, "previous", 2, "previous"))); + var result = new CompletableFuture(); + Thread writer = new Thread(() -> data.mutateFinalData(yaml -> { + yaml.set("TimedPermissions.committed", java.util.List.of("candidate")); + yaml.set("Nested.branch.committed", "candidate"); + yaml.set("Nested.branch.removed", null); + yaml.set("Nested.branch.competingRemoval", null); + yaml.set("Nested.branch.competingValue", "candidate"); + yaml.set("Nested.removedTree", null); + yaml.set("Nested.addedTree.committed", "candidate"); + yaml.set("Nested.addedTree.competing", "candidate"); + yaml.set("Nested.scalarToSection", null); + yaml.set("Nested.scalarToSection.child", "candidate"); + yaml.set("Nested.competingScalarToSection", null); + yaml.set("Nested.competingScalarToSection.child", "candidate"); + yaml.set("Nested.sectionToScalar", "candidate"); + yaml.set("Nested.competingSectionToScalar", "candidate"); + yaml.set("Nested.concurrentlyRemoved.committed", "candidate"); + yaml.set("Nested.concurrentlyReplaced.committed", "candidate"); + yaml.set("RawMap.committed", "candidate"); + yaml.set("RawMap.removed", null); + yaml.set("RawMap.competing", "candidate"); + yaml.set("RawMap.numeric.1", "candidate"); + }).whenComplete((ignored, failure) -> { + if (failure == null) result.complete(null); else result.completeExceptionally(failure); + }), "nested-final-server-data-publication"); + try { + writer.start(); + assertTrue(data.forceStarted.await(2, TimeUnit.SECONDS)); + live.set("TimedPermissions.existing", java.util.List.of("concurrent")); + live.set("Nested.branch.existing", "concurrent"); + live.set("Nested.branch.competingRemoval", "concurrent"); + live.set("Nested.branch.competingValue", "concurrent"); + live.set("Nested.removedTree.competing", "concurrent"); + live.set("Nested.removedTree.added", "concurrent"); + live.set("Nested.addedTree.added", "concurrent"); + live.set("Nested.addedTree.competing", "concurrent"); + live.set("Nested.competingScalarToSection", "concurrent"); + live.set("Nested.competingSectionToScalar.child", "concurrent"); + live.set("Nested.concurrentlyRemoved", null); + live.set("Nested.concurrentlyReplaced", "concurrent"); + live.set("RawMap", Map.of("existing", "concurrent", "removed", "previous", + "competing", "concurrent", "added", "concurrent", + "numeric", Map.of(1, "previous", 2, "concurrent", 3, "concurrent"))); + } finally { + data.releaseForce.countDown(); + writer.join(2000); + if (writer.isAlive()) writer.interrupt(); + } + assertFalse(writer.isAlive()); + if (failForce) { + ExecutionException failure = assertThrows(ExecutionException.class, + () -> result.get(2, TimeUnit.SECONDS)); + assertInstanceOf(java.io.UncheckedIOException.class, failure.getCause()); + assertSame(data.forceFailure, failure.getCause().getCause()); + } else result.get(2, TimeUnit.SECONDS); + assertSame(live, data.getData()); + var published = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals(java.util.List.of("candidate"), published.getStringList("TimedPermissions.committed")); + assertEquals(java.util.List.of("previous"), published.getStringList("TimedPermissions.existing")); + assertEquals("candidate", published.getString("Nested.branch.committed")); + assertNestedFinalValues(live); + data.failing = false; + if (failForce) assertQuarantinedAfterUnconfirmedFinalPublication(data); + else { + data.setLastUpdated(); + assertNestedFinalValues(YamlConfiguration.loadConfiguration(data.getdFile())); + } + assertEquals(failForce, result.isCompletedExceptionally()); + } + } + + private static void assertQuarantinedAfterUnconfirmedFinalPublication(FailingDirectoryForceServerData data) + throws Exception { + byte[] visible = Files.readAllBytes(data.getdFile().toPath()); + assertEquals(2, data.failedForces, "only one settlement retry is allowed"); + assertThrows(IllegalStateException.class, data::setLastUpdated); + assertThrows(IllegalStateException.class, data::saveData); + assertThrows(IllegalStateException.class, data::saveDataChecked); + assertThrows(IllegalStateException.class, data::reloadData); + assertThrows(IllegalStateException.class, data::setup); + var callbackCalled = new java.util.concurrent.atomic.AtomicBoolean(); + assertThrows(IllegalStateException.class, () -> data.mutateOrdinaryData(yaml -> callbackCalled.set(true))); + assertFalse(callbackCalled.get(), "an unconfirmed final publication fences the callback itself"); + assertThrows(CompletionException.class, + () -> data.mutateFinalData(yaml -> yaml.set("UnacknowledgedSuccessor", true)).toCompletableFuture().join()); + assertFalse(data.getData().contains("UnacknowledgedSuccessor")); + assertArrayEquals(visible, Files.readAllBytes(data.getdFile().toPath()), + "later operations cannot silently make an unacknowledged final candidate durable"); + } + + private static void assertNestedFinalValues(FileConfiguration yaml) { + assertEquals(java.util.List.of("candidate"), yaml.getStringList("TimedPermissions.committed")); + assertEquals(java.util.List.of("concurrent"), yaml.getStringList("TimedPermissions.existing")); + assertEquals("candidate", yaml.getString("Nested.branch.committed")); + assertEquals("concurrent", yaml.getString("Nested.branch.existing")); + assertFalse(yaml.contains("Nested.branch.removed")); + assertEquals("concurrent", yaml.getString("Nested.branch.competingRemoval")); + assertEquals("concurrent", yaml.getString("Nested.branch.competingValue")); + assertFalse(yaml.contains("Nested.removedTree.unchanged")); + assertEquals("concurrent", yaml.getString("Nested.removedTree.competing")); + assertEquals("concurrent", yaml.getString("Nested.removedTree.added")); + assertEquals("candidate", yaml.getString("Nested.addedTree.committed")); + assertEquals("concurrent", yaml.getString("Nested.addedTree.competing")); + assertEquals("concurrent", yaml.getString("Nested.addedTree.added")); + assertEquals("candidate", yaml.getString("Nested.scalarToSection.child")); + assertEquals("concurrent", yaml.getString("Nested.competingScalarToSection")); + assertEquals("candidate", yaml.getString("Nested.sectionToScalar")); + assertEquals("concurrent", yaml.getString("Nested.competingSectionToScalar.child")); + assertFalse(yaml.contains("Nested.concurrentlyRemoved")); + assertEquals("concurrent", yaml.getString("Nested.concurrentlyReplaced")); + assertEquals("candidate", yaml.getString("RawMap.committed")); + assertEquals("concurrent", yaml.getString("RawMap.existing")); + assertEquals("concurrent", yaml.getString("RawMap.competing")); + assertEquals("concurrent", yaml.getString("RawMap.added")); + assertFalse(yaml.contains("RawMap.removed")); + assertEquals("candidate", yaml.getString("RawMap.numeric.1")); + assertEquals("concurrent", yaml.getString("RawMap.numeric.2")); + assertEquals("concurrent", yaml.getString("RawMap.numeric.3")); + } + + @Test void legacyFinalMutationReachesLiveOwnerAndSavedYamlExactlyOnceWithoutClaimingDurability() { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + LegacySavingOwner data = legacySavingOwner(); + FileConfiguration live = data.getData(); + var mutations = new java.util.concurrent.atomic.AtomicInteger(); + var failure = assertThrows(CompletionException.class, () -> data.mutateFinalData(yaml -> { + mutations.incrementAndGet(); + assertSame(live, yaml, "a legacy save override publishes its existing live YAML"); + yaml.set("FullInventory.example.Items.0", "stored-item"); + yaml.set("TimedPermissions.example", java.util.List.of("example.use%line%12345")); + yaml.set("LastUpdated", 2468L); + }).toCompletableFuture().join()); + + assertInstanceOf(UnsupportedOperationException.class, failure.getCause(), + "returning from a void save does not confirm durable publication"); + assertEquals(1, mutations.get()); + assertEquals(1, data.saves); + assertSame(live, data.getData()); + assertSame(live, data.savedData); + assertEquals(2468L, live.getLong("LastUpdated")); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals("stored-item", saved.getString("FullInventory.example.Items.0")); + assertEquals(java.util.List.of("example.use%line%12345"), saved.getStringList("TimedPermissions.example")); + assertEquals(2468L, saved.getLong("LastUpdated")); + } + } + + @Test void legacyFinalMutationFailureIsObservableWithoutInvokingSave() { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + LegacySavingOwner data = legacySavingOwner(); + FileConfiguration live = data.getData(); + var mutations = new java.util.concurrent.atomic.AtomicInteger(); + var mutationFailure = new IllegalStateException("legacy mutation failed"); + var failure = assertThrows(CompletionException.class, () -> data.mutateFinalData(yaml -> { + mutations.incrementAndGet(); + yaml.set("LastUpdated", 2468L); + throw mutationFailure; + }).toCompletableFuture().join()); + + assertSame(mutationFailure, failure.getCause()); + assertEquals(1, mutations.get()); + assertEquals(0, data.saves, "a failed legacy mutation must not proceed to save"); + assertSame(live, data.getData()); + assertEquals(2468L, live.getLong("LastUpdated"), "legacy mutation retains its existing live-YAML semantics"); + assertFalse(data.getdFile().exists()); + } + } + + @Test void legacyFinalSaveFailureIsObservableAfterExactlyOneMutationAndOneSave() { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + LegacySavingOwner data = legacySavingOwner(); + FileConfiguration live = data.getData(); + var mutations = new java.util.concurrent.atomic.AtomicInteger(); + data.saveFailure = new IllegalStateException("legacy save failed"); + var failure = assertThrows(CompletionException.class, () -> data.mutateFinalData(yaml -> { + mutations.incrementAndGet(); + yaml.set("LastUpdated", 2468L); + }).toCompletableFuture().join()); + + assertSame(data.saveFailure, failure.getCause()); + assertEquals(1, mutations.get()); + assertEquals(1, data.saves); + assertSame(live, data.getData()); + assertSame(live, data.savedData); + assertEquals(2468L, data.savedData.getLong("LastUpdated")); + assertFalse(data.getdFile().exists()); + } + } + + + @Test void inheritedSetValueCannotMutateLiveYamlDuringNativePublication() throws Exception { + verifyInheritedMutatorIsPublicationOrdered(data -> data.setValue("Inherited.Value", "new"), + "Inherited.Value"); + } + + @Test void ordinaryCallbackCannotMutateLiveYamlDuringNativeFinalPublication() throws Exception { + verifyInheritedMutatorIsPublicationOrdered(data -> data.mutateOrdinaryData(yaml -> { + assertTrue(Thread.holdsLock(data.publicationMonitor())); + yaml.set("Ordinary.Value", "new"); + }), "Ordinary.Value"); + } + + @Test void inheritedCreateSectionCannotMutateLiveYamlDuringNativePublication() throws Exception { + verifyInheritedMutatorIsPublicationOrdered(data -> data.createSection("Inherited.Section"), + "Inherited.Section"); + } + + @Test void inheritedBulkUpdateCannotMutateLiveYamlDuringNativePublicationOrImplicitlySave() throws Exception { + YamlConfiguration updates = new YamlConfiguration(); + updates.set("Bulk.Value", "new"); + BarrierServerData data = verifyInheritedMutatorIsPublicationOrdered(owner -> owner.setData(updates), + "Bulk.Value"); + assertFalse(YamlConfiguration.loadConfiguration(data.getdFile()).contains("Bulk.Value"), + "the inherited bulk update must preserve its no-automatic-save contract"); + data.saveData(); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals("new", saved.getString("Bulk.Value")); + assertEquals(2L, saved.getLong("Inherited.Final")); + } + + @Test void inheritedReloadWaitsForFinalOverflowPublicationAndLaterOrdinarySaveKeepsAcknowledgedItems() + throws Exception { + ConfigurationSerialization.registerClass(StoredItem.class, "ACPublicationOrderingOverflow"); + FullInventoryHandler inventoryHandler = null; + var releaseTimer = new CountDownLatch(1); + var persistence = Executors.newSingleThreadExecutor(AdvancedCoreRuntime.daemonThreads("reload-publication-test")); + BarrierServerData data = null; + Thread reload = null; + try (var schedulerConstruction = mockConstruction(BukkitScheduler.class); + var bukkit = mockStatic(Bukkit.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + Server server = mock(Server.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(mock(org.bukkit.scheduler.BukkitScheduler.class)); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + data = new BarrierServerData(plugin); + data.setup(); + when(plugin.getServerDataFile()).thenReturn(data); + inventoryHandler = new FullInventoryHandler(plugin); + var timerEntered = new CountDownLatch(1); + inventoryHandler.getTimer().execute(() -> { + timerEntered.countDown(); + try { releaseTimer.await(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + }); + assertTrue(timerEntered.await(2, TimeUnit.SECONDS)); + + UUID uuid = UUID.randomUUID(); + Player player = mock(Player.class); + PlayerInventory inventory = mock(PlayerInventory.class); + ItemStack item = new StoredItem(); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getInventory()).thenReturn(inventory); + when(inventory.addItem(item)).thenReturn(new HashMap<>(Map.of(0, item))); + bukkit.when(() -> Bukkit.getPlayer(uuid)).thenReturn(player); + inventoryHandler.getLastMessageTime().put(uuid, System.currentTimeMillis()); + var deliveryTask = new AtomicReference(); + doAnswer(call -> { deliveryTask.set(call.getArgument(1)); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + CompletableFuture delivery = inventoryHandler.giveItemAsync(player, item).toCompletableFuture(); + deliveryTask.get().run(); + assertFalse(delivery.isDone(), "reserved overflow is not acknowledged before publication"); + + FileConfiguration before = data.getData(); + data.blockFinalPublication = true; + CompletableFuture finalSave = inventoryHandler.shutdownAsync(persistence).toCompletableFuture(); + assertTrue(data.finalPublicationStarted.await(2, TimeUnit.SECONDS)); + var reloadStarted = new CountDownLatch(1); + var reloaded = new CompletableFuture(); + BarrierServerData owner = data; + reload = new Thread(() -> { + reloadStarted.countDown(); + try { owner.reloadData(); reloaded.complete(null); } + catch (Throwable failure) { reloaded.completeExceptionally(failure); } + }, "inherited-server-data-reload"); + reload.start(); + assertTrue(reloadStarted.await(2, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (reload.getState() != Thread.State.BLOCKED && !reloaded.isDone() + && System.nanoTime() < deadline) Thread.yield(); + assertEquals(Thread.State.BLOCKED, reload.getState(), "reload must wait for native publication"); + assertSame(before, data.getData(), "pending publication cannot lose its live configuration identity"); + assertFalse(delivery.isDone()); + assertFalse(finalSave.isDone()); + + data.releaseFinalPublication.countDown(); + finalSave.get(2, TimeUnit.SECONDS); + delivery.get(2, TimeUnit.SECONDS); + reloaded.get(2, TimeUnit.SECONDS); + assertNotSame(before, data.getData(), "reload still replaces the configuration after publication"); + String path = "FullInventory." + uuid + ".Items.0"; + assertEquals(item, data.getData().getItemStack(path)); + data.setLastUpdated(); + YamlConfiguration saved = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals(item, saved.getItemStack(path), "later whole-file save must retain acknowledged overflow"); + assertTrue(saved.getLong("LastUpdated") > 0L); + verify(inventory, times(1)).addItem(item); + } finally { + if (data != null) data.releaseFinalPublication.countDown(); + releaseTimer.countDown(); + if (inventoryHandler != null) inventoryHandler.getTimer().shutdownNow(); + persistence.shutdownNow(); + if (reload != null) { + reload.join(2000); + if (reload.isAlive()) reload.interrupt(); + } + try { + assertTrue(persistence.awaitTermination(2, TimeUnit.SECONDS)); + if (inventoryHandler != null) + assertTrue(inventoryHandler.getTimer().awaitTermination(2, TimeUnit.SECONDS)); + if (reload != null) assertFalse(reload.isAlive()); + } finally { ConfigurationSerialization.unregisterClass(StoredItem.class); } + } + } + + @Test void customPublicationOwnersKeepNonblockingInheritedReloadAndBulkUpdate() throws Exception { + try (var scheduler = mockConstruction(BukkitScheduler.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + Files.writeString(dataFolder.toPath().resolve("ServerData.yml"), "Stored: from-disk\n"); + CustomOwner data = new CustomOwner(plugin); + data.setData(new YamlConfiguration()); + var ownerHeld = new CountDownLatch(1); + var releaseOwner = new CountDownLatch(1); + var updated = new CompletableFuture(); + Thread holder = new Thread(() -> { + synchronized (data) { + ownerHeld.countDown(); + try { releaseOwner.await(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } + }, "custom-publication-owner"); + Thread writer = new Thread(() -> { + try { + YamlConfiguration bulk = new YamlConfiguration(); + bulk.set("Bulk.Value", "new"); + data.setData(bulk); + assertEquals("new", data.getData().getString("Bulk.Value")); + data.reloadData(); + updated.complete(null); + } catch (Throwable failure) { updated.completeExceptionally(failure); } + }, "custom-inherited-configuration-update"); + try { + holder.start(); + assertTrue(ownerHeld.await(2, TimeUnit.SECONDS)); + writer.start(); + updated.get(2, TimeUnit.SECONDS); + assertEquals("from-disk", data.getData().getString("Stored")); + assertEquals(0, data.saved.get(), "bulk updates and reloads must not add saves"); + } finally { + releaseOwner.countDown(); + holder.join(2000); writer.join(2000); + if (holder.isAlive()) holder.interrupt(); + if (writer.isAlive()) writer.interrupt(); + } + assertFalse(holder.isAlive() || writer.isAlive()); + } + } + + @Test void customPublicationOwnersKeepNonblockingOrdinaryMutators() throws Exception { + try (var scheduler = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + CustomOwner data = new CustomOwner(plugin); + data.setData(new YamlConfiguration()); + var ownerHeld = new CountDownLatch(1); + var releaseOwner = new CountDownLatch(1); + var written = new CompletableFuture(); + Thread holder = new Thread(() -> { + synchronized (data) { + ownerHeld.countDown(); + try { releaseOwner.await(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + } + }, "custom-publication-owner"); + Thread writer = new Thread(() -> { + try { + data.setData("Example", "value"); data.setIgnoreTime(true); data.setLastUpdated(); + data.setPrevDay(3); data.setPrevWeekDay(4); data.setPrevMonth("example-period"); + var source = mock(org.bukkit.plugin.Plugin.class); + when(source.getName()).thenReturn("ExamplePlugin"); + when(source.getDescription()).thenReturn(new org.bukkit.plugin.PluginDescriptionFile( + "ExamplePlugin", "1.0", "example.Main")); + data.setPluginVersion(source); + data.mutateOrdinaryData(yaml -> yaml.set("Custom.Callback", "value")); + written.complete(null); + } catch (Throwable failure) { written.completeExceptionally(failure); } + }, "custom-ordinary-mutation"); + try { + holder.start(); assertTrue(ownerHeld.await(2, TimeUnit.SECONDS)); writer.start(); + written.get(2, TimeUnit.SECONDS); + assertEquals(8, data.saved.get()); + assertEquals("value", data.getData().getString("Custom.Callback")); + assertEquals(3, data.getData().getInt("PrevDay")); + } finally { + releaseOwner.countDown(); holder.join(2000); writer.join(2000); + if (holder.isAlive()) holder.interrupt(); if (writer.isAlive()) writer.interrupt(); + } + assertFalse(holder.isAlive() || writer.isAlive()); + } + } + + private static final class CustomOwner extends ServerData { + final java.util.concurrent.atomic.AtomicInteger saved = new java.util.concurrent.atomic.AtomicInteger(); + CustomOwner(AdvancedCorePlugin plugin) { super(plugin); } + @Override public void saveData() { saved.incrementAndGet(); } + } + + private LegacySavingOwner legacySavingOwner() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + LegacySavingOwner data = new LegacySavingOwner(plugin); + data.setData(new YamlConfiguration()); + return data; + } + + private static final class LegacySavingOwner extends ServerData { + int saves; + FileConfiguration savedData; + RuntimeException saveFailure; + LegacySavingOwner(AdvancedCorePlugin plugin) { super(plugin); } + @Override public void saveData() { + saves++; + savedData = getData(); + if (saveFailure != null) throw saveFailure; + try { savedData.save(getdFile()); } + catch (java.io.IOException failure) { throw new java.io.UncheckedIOException(failure); } + } + } + + private void verifyPublicationOrder(boolean heartbeat) throws Exception { + try (var scheduler = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + verifyNativePublicationOrder(heartbeat); + } + } + + private BarrierServerData verifyInheritedMutatorIsPublicationOrdered(java.util.function.Consumer mutator, + String path) throws Exception { + try (var scheduler = mockConstruction(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + BarrierServerData data = new BarrierServerData(plugin); + data.install(); data.blockFinalPublication = true; + var finalResult = new CompletableFuture(); + Thread finalWriter = new Thread(() -> data.mutateFinalData(yaml -> yaml.set("Inherited.Final", 2L)) + .whenComplete((ignored, failure) -> { + if (failure == null) finalResult.complete(null); else finalResult.completeExceptionally(failure); + }), "final-server-data-save"); + Thread inheritedWriter = new Thread(() -> mutator.accept(data), "inherited-server-data-mutator"); + try { + finalWriter.start(); + assertTrue(data.finalPublicationStarted.await(2, TimeUnit.SECONDS)); + inheritedWriter.start(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (inheritedWriter.getState() != Thread.State.BLOCKED && System.nanoTime() < deadline) + Thread.yield(); + assertEquals(Thread.State.BLOCKED, inheritedWriter.getState()); + assertFalse(data.getData().contains(path), "inherited mutator changed live YAML before publication"); + } finally { + data.releaseFinalPublication.countDown(); + data.releaseOrdinaryWrite.countDown(); + finalWriter.join(2000); inheritedWriter.join(2000); + if (finalWriter.isAlive()) finalWriter.interrupt(); + if (inheritedWriter.isAlive()) inheritedWriter.interrupt(); + } + assertFalse(finalWriter.isAlive() || inheritedWriter.isAlive()); + finalResult.get(2, TimeUnit.SECONDS); + assertTrue(data.getData().contains(path)); + assertEquals(2L, data.getData().getLong("Inherited.Final")); + assertEquals(2L, YamlConfiguration.loadConfiguration(data.getdFile()).getLong("Inherited.Final")); + return data; + } + } + private void verifyNativePublicationOrder(boolean heartbeat) throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getDataFolder()).thenReturn(dataFolder); + BarrierServerData data = new BarrierServerData(plugin); + data.install(); data.getData().set("LastUpdated", 1L); + var ordinaryResult = new CompletableFuture(); + var finalResult = new CompletableFuture(); + var finalAttempted = new CountDownLatch(1); + Thread ordinary = new Thread(() -> { + try { + if (heartbeat) data.setLastUpdated(); else data.saveData(); + ordinaryResult.complete(null); + } catch (Throwable failure) { ordinaryResult.completeExceptionally(failure); } + }, "ordinary-server-data-save"); + Thread finalWriter = new Thread(() -> { + finalAttempted.countDown(); + data.mutateFinalData(yaml -> yaml.set("LastUpdated", 2L)).whenComplete((ignored, failure) -> { + if (failure == null) finalResult.complete(null); else finalResult.completeExceptionally(failure); + }); + }, "final-server-data-save"); + try { + ordinary.start(); + assertTrue(data.ordinaryWriteStarted.await(2, TimeUnit.SECONDS)); + finalWriter.start(); + assertTrue(finalAttempted.await(2, TimeUnit.SECONDS)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (finalWriter.getState() != Thread.State.BLOCKED && !finalResult.isDone() + && System.nanoTime() < deadline) Thread.yield(); + assertFalse(finalResult.isDone(), "a final receipt cannot settle ahead of an older native publication"); + assertEquals(Thread.State.BLOCKED, finalWriter.getState()); + } finally { + data.releaseOrdinaryWrite.countDown(); + ordinary.join(2000); finalWriter.join(2000); + if (ordinary.isAlive()) ordinary.interrupt(); + if (finalWriter.isAlive()) finalWriter.interrupt(); + } + assertFalse(ordinary.isAlive() || finalWriter.isAlive()); + ordinaryResult.get(2, TimeUnit.SECONDS); finalResult.get(2, TimeUnit.SECONDS); + assertEquals(2L, YamlConfiguration.loadConfiguration(data.getdFile()).getLong("LastUpdated")); + } + + @SerializableAs("ACPublicationOrderingOverflow") + public static final class StoredItem extends ItemStack { + public StoredItem() { super(Material.STONE, 1); } + @Override public Map serialize() { return Map.of("amount", 1); } + public static StoredItem deserialize(Map values) { return new StoredItem(); } + @Override public boolean equals(Object value) { return value instanceof StoredItem; } + @Override public int hashCode() { return 1; } + } + + private static final class BarrierServerData extends ServerData { + final CountDownLatch ordinaryWriteStarted = new CountDownLatch(1); + final CountDownLatch releaseOrdinaryWrite = new CountDownLatch(1); + final CountDownLatch finalPublicationStarted = new CountDownLatch(1); + final CountDownLatch releaseFinalPublication = new CountDownLatch(1); + BarrierServerData(AdvancedCorePlugin plugin) { super(plugin); } + + boolean blockFinalPublication; + @Override + protected void moveAtomically(java.nio.file.Path source, java.nio.file.Path target) throws java.io.IOException { + if (!blockFinalPublication) { super.moveAtomically(source, target); return; } + finalPublicationStarted.countDown(); + try { + if (!releaseFinalPublication.await(5, TimeUnit.SECONDS)) + throw new java.io.IOException("synthetic final publication timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); + } + super.moveAtomically(source, target); + } + void install() { + setData(new YamlConfiguration() { + @Override public void save(File file) throws java.io.IOException { + String captured = saveToString(); + ordinaryWriteStarted.countDown(); + try { + if (!releaseOrdinaryWrite.await(5, TimeUnit.SECONDS)) + throw new java.io.IOException("synthetic ordinary publication timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); + } + Files.writeString(file.toPath(), captured, StandardCharsets.UTF_8); + } + }); + } + } + + private static final class FailingDirectoryForceServerData extends ServerData { + final CountDownLatch forceStarted = new CountDownLatch(1); + final CountDownLatch releaseForce = new CountDownLatch(1); + final java.io.IOException forceFailure = new java.io.IOException("synthetic directory force failure"); + boolean failing = true; + boolean failAfterBarrier = true; + int failedForces; + FailingDirectoryForceServerData(AdvancedCorePlugin plugin) { super(plugin); } + @Override protected void forceDirectory(java.nio.file.Path directory) throws java.io.IOException { + if (!failing) { super.forceDirectory(directory); return; } + forceStarted.countDown(); + try { + if (!releaseForce.await(5, TimeUnit.SECONDS)) throw new java.io.IOException("force barrier timed out"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new java.io.IOException(interrupted); + } + if (failAfterBarrier) { failedForces++; throw forceFailure; } + super.forceDirectory(directory); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ShutdownProcessPersistenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ShutdownProcessPersistenceTest.java new file mode 100644 index 0000000000..ad34fcd2c6 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/ShutdownProcessPersistenceTest.java @@ -0,0 +1,106 @@ +package com.bencodez.advancedcore.tests.lifecycle; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.nio.file.Files; +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.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.data.ServerData; +import com.bencodez.advancedcore.lifecycle.ServerDataOwnership; + +class ShutdownProcessPersistenceTest { + @TempDir Path directory; + + @Test void admittedTimestampPublishesBeforeOrderlyJvmExit() throws Exception { + runProbe("complete"); + assertTrue(Files.exists(directory.resolve("result")), "admitted publication must survive orderly JVM exit"); + assertEquals("published", Files.readString(directory.resolve("result"))); + } + + @Test void admittedTimestampSurvivesDisableInsideAnExistingHostShutdownHook() throws Exception { + runProbe("host-shutdown"); + assertTrue(Files.exists(directory.resolve("result")), "host-hook shutdown must preserve admitted publication"); + assertEquals("published", Files.readString(directory.resolve("result"))); + } + + @Test void blockedPublicationCannotRestartTheSharedShutdownDeadline() throws Exception { + runProbe("blocked"); + assertFalse(Files.exists(directory.resolve("result"))); + } + + private void runProbe(String mode) throws Exception { + var process = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(), + "-XX:+EnableDynamicAgentLoading", "-cp", System.getProperty("surefire.test.class.path", + System.getProperty("java.class.path")), Probe.class.getName(), directory.toString(), mode) + .redirectErrorStream(true).redirectOutput(directory.resolve("child-output").toFile()).start(); + try { + long admissionDeadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(12); + while (!Files.exists(directory.resolve("ready")) && process.isAlive() + && System.nanoTime() < admissionDeadline) Thread.sleep(10); + assertTrue(Files.exists(directory.resolve("ready")), "isolated JVM must reach actual shutdown"); + assertTrue(process.waitFor(4, TimeUnit.SECONDS), "final persistence cannot renew the spent shutdown budget"); + assertEquals(0, process.exitValue(), "isolated shutdown must finish cleanly"); + } finally { + if (process.isAlive()) { process.destroyForcibly(); process.waitFor(5, TimeUnit.SECONDS); } + } + } + + public static final class Probe { + public static void main(String[] args) throws Exception { + Path directory = Path.of(args[0]); + boolean blocked = args[1].equals("blocked"); + var plugin = mock(AdvancedCorePlugin.class); + when(plugin.isLoadServerData()).thenReturn(true); + var owner = mock(ServerData.class); + when(plugin.getLogger()).thenReturn(Logger.getLogger("shutdown-fixture")); + when(plugin.getServerDataFile()).thenReturn(owner); + var publication = new CompletableFuture(); + when(owner.mutateFinalData(any())).thenAnswer(call -> { + if (!blocked) { + Thread writer = new Thread(() -> { + try { + Thread.sleep(300); + Files.writeString(directory.resolve("result"), "published"); + publication.complete(null); + } catch (Throwable failure) { publication.completeExceptionally(failure); } + }, "fixture-publication"); + writer.setDaemon(true); writer.start(); + } + return publication; + }); + var ownership = ServerDataOwnership.acquire(directory.resolve("ServerData.yml")); + var lockField = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lockField.setAccessible(true); lockField.set(plugin, new Object()); + var ownerField = AdvancedCorePlugin.class.getDeclaredField("serverDataOwnership"); + ownerField.setAccessible(true); ownerField.set(plugin, ownership); + doCallRealMethod().when(plugin).hasServerDataOwnership(); + doCallRealMethod().when(plugin).releaseServerDataOwnershipAfter(any()); + var runtime = new AdvancedCoreRuntime(new BukkitRuntimePlatform(plugin)); + Files.writeString(directory.resolve("ready"), "ready"); + long started = System.nanoTime(); + if (blocked) started -= TimeUnit.SECONDS.toNanos(15) - TimeUnit.MILLISECONDS.toNanos(250); + final long shutdownStarted = started; + Runnable disable = () -> { + ownership.beginShutdown(shutdownStarted); + runtime.shutdown(shutdownStarted); + }; + if (args[1].equals("host-shutdown")) { + Runtime.getRuntime().addShutdownHook(new Thread(disable, "fixture-host-stop")); + } else { + disable.run(); + } + System.exit(0); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardHandlerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardHandlerTest.java index dbd5aecba0..9623311313 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardHandlerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardHandlerTest.java @@ -1,11 +1,16 @@ package com.bencodez.advancedcore.tests.rewards; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import java.io.File; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; import java.util.logging.Logger; import org.bukkit.configuration.ConfigurationSection; @@ -18,6 +23,7 @@ import com.bencodez.advancedcore.api.rewards.DirectlyDefinedReward; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.SubDirectlyDefinedReward; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; public class RewardHandlerTest { @@ -25,10 +31,11 @@ public class RewardHandlerTest { File tempDir; private RewardHandler rewardHandler; + private AdvancedCorePlugin plugin; @BeforeEach public void setUp() { - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + plugin = mock(AdvancedCorePlugin.class); when(plugin.getDataFolder()).thenReturn(tempDir); when(plugin.getLogger()).thenReturn(mock(Logger.class)); @@ -40,6 +47,34 @@ public void tearDown() { rewardHandler.getDelayedTimer().shutdownNow(); } + @Test + public void beginShutdownClosesEligibilityAndWaitsForAdmittedReplayChecks() throws Exception { + assertShutdownClosesEligibility(false); + } + + @Test + public void boundedShutdownClosesEligibilityAndWaitsForAdmittedReplayChecks() throws Exception { + assertShutdownClosesEligibility(true); + } + + private void assertShutdownClosesEligibility(boolean bounded) throws Exception { + when(plugin.isEnabled()).thenReturn(true); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + when(user.getPlugin()).thenReturn(plugin); + try (var admitted = rewardHandler.admitPersistedReplayCheck()) { + assertNotNull(admitted); + if (bounded) rewardHandler.shutdown(0, TimeUnit.NANOSECONDS); + var retirement = rewardHandler.beginShutdown().toCompletableFuture(); + assertNull(rewardHandler.admitPersistedReplayCheck()); + CompletionException failure = assertThrows(CompletionException.class, + () -> rewardHandler.getRewardEligibility().signal(user).toCompletableFuture().join()); + assertTrue(failure.getCause() instanceof IllegalStateException); + assertFalse(retirement.isDone(), "shutdown must retain an already admitted replay check"); + } + rewardHandler.beginShutdown().toCompletableFuture().get(2, TimeUnit.SECONDS); + assertTrue(rewardHandler.getDelayedTimer().isTerminated()); + } + @Test public void getSubDirectlyDefinedMatchesFileStylePathWithUnderscoresInParentName() { rewardHandler.getSubDirectlyDefinedRewards() diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/ServerDataTimeTransitionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/ServerDataTimeTransitionTest.java index fddfcf3931..9e5e099103 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/ServerDataTimeTransitionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/ServerDataTimeTransitionTest.java @@ -3,6 +3,8 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -62,6 +64,457 @@ void failedCompletionSaveRestoresPendingStateAndPreviousMarker() { assertTrue(data.getData().getBoolean("TimeTransitions.DAY.Pending")); } + @Test + void checkedShutdownSnapshotPersistsAndPropagatesActualReplacementFailure() throws Exception { + TestServerData data = serverData(); + data.setup(); + data.getData().set("TimedPermissions.example", java.util.List.of("example.use%line%1234")); + data.saveDataChecked(); + assertEquals(java.util.List.of("example.use%line%1234"), + YamlConfiguration.loadConfiguration(data.getdFile()).getStringList("TimedPermissions.example")); + File target = data.getdFile(); + assertTrue(target.delete()); + assertTrue(target.mkdir()); + Files.writeString(target.toPath().resolve("blocks-replacement"), "occupied"); + assertThrows(UncheckedIOException.class, data::saveDataChecked); + } + + @Test + void finalMutationUsesNativeCheckedPublicationAndReportsFailure() throws Exception { + TestServerData data = serverData(); + data.setup(); + data.mutateFinalData(yaml -> yaml.set("LastUpdated", 1234L)).toCompletableFuture().join(); + assertEquals(1234L, YamlConfiguration.loadConfiguration(data.getdFile()).getLong("LastUpdated")); + File target = data.getdFile(); + assertTrue(target.delete()); assertTrue(target.mkdir()); + Files.writeString(target.toPath().resolve("blocks-replacement"), "occupied"); + var failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> data.mutateFinalData(yaml -> yaml.set("LastUpdated", 5678L)).toCompletableFuture().join()); + assertTrue(failure.getCause() instanceof UncheckedIOException); + assertEquals(1234L, data.getData().getLong("LastUpdated")); + } + + @Test + void legacyVoidSaveOwnerIsInvokedWithoutClaimingConfirmedPublication() { + var owner = new LegacySaveOwner(plugin()); owner.setup(); + var failure = assertThrows(java.util.concurrent.CompletionException.class, + () -> owner.mutateFinalData(yaml -> yaml.set("LastUpdated", 1234L)).toCompletableFuture().join()); + assertTrue(failure.getCause() instanceof UnsupportedOperationException); + assertEquals(1, owner.saves); + assertEquals(1234L, owner.getData().getLong("LastUpdated"), "legacy owners retain the established live mutation before their void save"); + } + + @Test + void failedFinalMutationCannotLeakPartialChangesIntoTheNextWholeFileSave() { + TestServerData data = serverData(); data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + assertThrows(java.util.concurrent.CompletionException.class, () -> data.mutateFinalData(yaml -> { + yaml.set("FullInventory", null); + yaml.set("FullInventory.unacknowledged.Time", 456L); + throw new IllegalStateException("mutation failed after editing its candidate"); + }).toCompletableFuture().join()); + data.mutateFinalData(yaml -> yaml.set("LastUpdated", 789L)).toCompletableFuture().join(); + var restarted = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals(123L, restarted.getLong("FullInventory.previous.Time")); + assertFalse(restarted.contains("FullInventory.unacknowledged")); + assertEquals(789L, restarted.getLong("LastUpdated")); + } + + @Test + void atomicVisibleFinalMutationSurvivesDirectoryForceFailureAndLaterSaves() throws Exception { + verifyVisibleFinalMutationSurvivesFailure(false, 1); + } + + @Test + void fallbackVisibleFinalMutationSurvivesDirectoryForceFailureAndLaterSaves() throws Exception { + verifyVisibleFinalMutationSurvivesFailure(true, 3); + } + + @Test + void fallbackVisibleFinalMutationSurvivesMarkerCleanupForceFailureAndLaterSaves() throws Exception { + verifyVisibleFinalMutationSurvivesFailure(true, 4); + } + + @Test + void fallbackVisibleFinalMutationSurvivesBackupCleanupForceFailureAndLaterSaves() throws Exception { + verifyVisibleFinalMutationSurvivesFailure(true, 5); + } + + private void verifyVisibleFinalMutationSurvivesFailure(boolean fallback, int failingForce) throws Exception { + PublicationFailureServerData data = new PublicationFailureServerData(plugin(), fallback); + data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + data.saveDataChecked(); + var live = data.getData(); + data.directoryForces = 0; + data.failingForce = failingForce; + var result = data.mutateFinalData(yaml -> { + yaml.set("FullInventory", null); + yaml.set("FullInventory.committed.Time", 456L); + yaml.set("FullInventory.committed.Items.0", "stored-item"); + }).toCompletableFuture(); + result.join(); + assertSame(live, data.getData()); + assertEquals(456L, live.getLong("FullInventory.committed.Time")); + assertFalse(live.contains("FullInventory.previous")); + assertEquals("stored-item", YamlConfiguration.loadConfiguration(data.getdFile()) + .getString("FullInventory.committed.Items.0")); + + data.failingForce = 0; + data.setLastUpdated(); + data.setData("TimedPermissions.example", java.util.List.of("example.use%line%1234")); + data.mutateFinalData(yaml -> yaml.set("IgnoreTime", true)).toCompletableFuture().join(); + assertFalse(result.isCompletedExceptionally(), "the original stage confirms its own settled publication"); + TestServerData restarted = serverData(); + restarted.setup(); + assertEquals(456L, restarted.getData().getLong("FullInventory.committed.Time")); + assertEquals("stored-item", restarted.getData().getString("FullInventory.committed.Items.0")); + assertFalse(restarted.getData().contains("FullInventory.previous")); + assertTrue(restarted.getLastUpdated() > 0L); + assertEquals(java.util.List.of("example.use%line%1234"), + restarted.getData().getStringList("TimedPermissions.example")); + assertTrue(restarted.isIgnoreTime()); + Path target = data.getdFile().toPath(); + assertFalse(Files.exists(target.resolveSibling("ServerData.yml.replacement-pending"))); + assertFalse(Files.exists(target.resolveSibling("ServerData.yml.backup"))); + } + + @Test + void failedFallbackCleanupQuarantinesOwnerAfterOneSettlementRetry() throws Exception { + PublicationFailureServerData data = new PublicationFailureServerData(plugin(), true); + data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + data.saveDataChecked(); + data.directoryForces = 0; + data.failFromForce = 3; + assertThrows(java.util.concurrent.CompletionException.class, () -> data.mutateFinalData( + yaml -> yaml.set("FullInventory.committed.Time", 456L)).toCompletableFuture().join()); + byte[] visibleSnapshot = Files.readAllBytes(data.getdFile().toPath()); + assertEquals(4, data.directoryForces, "two preparation forces, failed target force and one retry"); + data.failFromForce = 0; + assertThrows(IllegalStateException.class, data::setLastUpdated); + assertThrows(IllegalStateException.class, data::saveData); + assertThrows(IllegalStateException.class, data::saveDataChecked); + assertThrows(IllegalStateException.class, data::reloadData); + assertThrows(IllegalStateException.class, data::setup); + assertArrayEquals(visibleSnapshot, Files.readAllBytes(data.getdFile().toPath())); + } + + @Test + void failedFallbackBeforeMoveDiscardsCandidateAndAllowsLaterSave() throws Exception { + PublicationFailureServerData data = new PublicationFailureServerData(plugin(), true); + data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + data.saveDataChecked(); + data.directoryForces = 0; + data.failingForce = 2; + assertThrows(java.util.concurrent.CompletionException.class, () -> data.mutateFinalData( + yaml -> yaml.set("FullInventory.unpublished.Time", 456L)).toCompletableFuture().join()); + assertFalse(data.getData().contains("FullInventory.unpublished")); + data.failingForce = 0; + data.setLastUpdated(); + TestServerData restarted = serverData(); + restarted.setup(); + assertEquals(123L, restarted.getData().getLong("FullInventory.previous.Time")); + assertFalse(restarted.getData().contains("FullInventory.unpublished")); + assertTrue(restarted.getLastUpdated() > 0L); + } + + @Test + void moveThatThrowsAfterReplacingSettlesAndAcknowledgesTheProvenCandidate() throws Exception { + MoveFailureServerData data = new MoveFailureServerData(plugin()); + data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + data.saveDataChecked(); + data.moveFailure = 1; + data.mutateFinalData(yaml -> yaml.set("FullInventory.committed.Time", 456L)).toCompletableFuture().join(); + assertEquals(456L, data.getData().getLong("FullInventory.committed.Time")); + data.moveFailure = 0; + data.setLastUpdated(); + assertEquals(456L, YamlConfiguration.loadConfiguration(data.getdFile()).getLong("FullInventory.committed.Time")); + } + + @Test + void unchangedCandidateCannotAcknowledgeMoveFailureBeforeReplacement() throws Exception { + verifyUnchangedCandidateMoveFailure(2); + } + + @Test + void unchangedCandidateCannotInferReplacementFromEqualHashes() throws Exception { + verifyUnchangedCandidateMoveFailure(1); + } + + private void verifyUnchangedCandidateMoveFailure(int moveFailure) throws Exception { + MoveFailureServerData data = new MoveFailureServerData(plugin()); + data.setup(); + data.getData().set("Stored", 123L); + // An ordinary visible file is not evidence that its contents were forced. + Files.writeString(data.getdFile().toPath(), data.getData().saveToString()); + byte[] original = Files.readAllBytes(data.getdFile().toPath()); + data.moveFailure = moveFailure; + assertThrows(java.util.concurrent.CompletionException.class, + () -> data.mutateFinalData(yaml -> { }).toCompletableFuture().join()); + assertArrayEquals(original, Files.readAllBytes(data.getdFile().toPath())); + // A subsequent confirmed replacement still succeeds; no no-op failure + // can manufacture a visibility transition or poison ordinary recovery. + data.moveFailure = 0; + data.mutateFinalData(yaml -> { }).toCompletableFuture().join(); + assertEquals(123L, YamlConfiguration.loadConfiguration(data.getdFile()).getLong("Stored")); + } + + @Test + void moveThatFailsBeforeReplacingDiscardsCandidateAndAllowsLaterSave() throws Exception { + MoveFailureServerData data = new MoveFailureServerData(plugin()); + data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + data.saveDataChecked(); + data.moveFailure = 2; + assertThrows(java.util.concurrent.CompletionException.class, () -> data.mutateFinalData( + yaml -> yaml.set("FullInventory.unpublished.Time", 456L)).toCompletableFuture().join()); + assertFalse(data.getData().contains("FullInventory.unpublished")); + data.moveFailure = 0; + data.setLastUpdated(); + var saved = YamlConfiguration.loadConfiguration(data.getdFile()); + assertEquals(123L, saved.getLong("FullInventory.previous.Time")); + assertFalse(saved.contains("FullInventory.unpublished")); + } + + @Test + void ambiguousMoveOutcomeCannotBeOverwrittenByLaterWholeFileSaves() throws Exception { + MoveFailureServerData data = new MoveFailureServerData(plugin()); + data.setup(); + data.getData().set("FullInventory.previous.Time", 123L); + data.saveDataChecked(); + data.moveFailure = 3; + assertThrows(java.util.concurrent.CompletionException.class, () -> data.mutateFinalData( + yaml -> yaml.set("FullInventory.unpublished.Time", 456L)).toCompletableFuture().join()); + byte[] unknownSnapshot = Files.readAllBytes(data.getdFile().toPath()); + data.moveFailure = 0; + var live = data.getData(); + String liveBefore = live.saveToString(); + var callbackCalled = new java.util.concurrent.atomic.AtomicBoolean(); + assertThrows(IllegalStateException.class, () -> data.mutateOrdinaryData(yaml -> { + callbackCalled.set(true); + yaml.set("Ordinary.Unconfirmed", 27); + })); + assertFalse(callbackCalled.get(), "unknown replacement must reject before invoking a live callback"); + assertThrows(IllegalStateException.class, data::setLastUpdated); + assertThrows(IllegalStateException.class, () -> data.setData("Ordinary.Value", 27)); + assertThrows(IllegalStateException.class, () -> data.setValue("Ordinary.Value", 27)); + assertThrows(IllegalStateException.class, () -> data.createSection("Ordinary.Section")); + var bulk = new YamlConfiguration(); + bulk.set("Ordinary.Bulk", 27); + assertThrows(IllegalStateException.class, () -> data.setData(bulk)); + assertThrows(IllegalStateException.class, data::reloadData); + assertThrows(IllegalStateException.class, data::setup); + assertThrows(IllegalStateException.class, data::saveData); + assertThrows(IllegalStateException.class, data::saveDataChecked); + assertThrows(java.util.concurrent.CompletionException.class, () -> data.mutateFinalData( + yaml -> yaml.set("IgnoreTime", true)).toCompletableFuture().join()); + assertArrayEquals(unknownSnapshot, Files.readAllBytes(data.getdFile().toPath())); + assertSame(live, data.getData()); + assertEquals(liveBefore, live.saveToString(), "rejected operations must not change live YAML"); + } + + @Test + void visibleTransitionInitializationIsRetainedAfterDirectoryForceFailure() throws Exception { + PublicationFailureServerData data = new PublicationFailureServerData(plugin(), false); + data.setup(); + data.failingForce = 1; + assertThrows(UncheckedIOException.class, + () -> data.beginTimeChangeTransition(TimeType.DAY, "2026-09-21", "21")); + assertTrue(data.getData().getBoolean("TimeTransitions.DAY.Pending")); + data.failEveryForce = true; + int forcesBeforeRead = data.directoryForces; + assertEquals("DAY:2026-09-21", data.getPendingTimeChangeTransition(TimeType.DAY).id()); + assertEquals(forcesBeforeRead, data.directoryForces, "pending observation remains memory-only after a save failure"); + assertThrows(UncheckedIOException.class, + () -> data.beginTimeChangeTransition(TimeType.DAY, "2026-09-21", "21")); + data.failEveryForce = false; + data.failingForce = 0; + assertEquals("DAY:2026-09-21", data.beginTimeChangeTransition(TimeType.DAY, "2026-09-21", "21").id()); + assertEquals("DAY:2026-09-21", data.getPendingTimeChangeTransition(TimeType.DAY).id()); + data.setLastUpdated(); + TestServerData restarted = serverData(); + restarted.setup(); + assertEquals("DAY:2026-09-21", restarted.getPendingTimeChangeTransition(TimeType.DAY).id()); + } + + @Test + void visibleTransitionCompletionIsRetainedAfterDirectoryForceFailure() throws Exception { + PublicationFailureServerData data = new PublicationFailureServerData(plugin(), false); + data.setup(); + data.getData().set("PrevDay", 20); + var transition = data.beginTimeChangeTransition(TimeType.DAY, "2026-09-21", "21"); + data.directoryForces = 0; + data.failingForce = 1; + assertThrows(UncheckedIOException.class, () -> data.completeTimeChangeTransition(transition)); + assertEquals(21, data.getPrevDay()); + assertFalse(data.getData().getBoolean("TimeTransitions.DAY.Pending")); + data.failingForce = 0; + data.setLastUpdated(); + TestServerData restarted = serverData(); + restarted.setup(); + assertEquals(21, restarted.getPrevDay()); + assertNull(restarted.getPendingTimeChangeTransition(TimeType.DAY)); + } + + @Test + void calendarWorkerRetriesVisiblePendingPublicationBeforeDispatchWithoutAnUnrelatedSave() throws Exception { + try (CalendarRetryFixture fixture = new CalendarRetryFixture()) { + fixture.data.failingForce = 1; + assertThrows(UncheckedIOException.class, + () -> fixture.data.beginTimeChangeTransition(TimeType.DAY, "2025-01-02", "2")); + fixture.data.failEveryForce = true; + fixture.requestCheck(); + assertTrue(fixture.dayIds.isEmpty(), "failed settlement cannot dispatch the retained pending record"); + assertFalse(fixture.checker.isActiveProcessing(), "failed admission must release its drain reservation"); + assertEquals("DAY:2025-01-02", fixture.data.getPendingTimeChangeTransition(TimeType.DAY).id()); + fixture.data.failEveryForce = false; + fixture.data.failingForce = 0; + fixture.requestCheck(); + assertEquals(java.util.List.of("DAY:2025-01-02"), fixture.dayIds); + assertEquals(java.util.List.of(3), fixture.dayPublicationForces, + "the worker settles the failed force before dispatching the original identity"); + assertNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + assertEquals(2, fixture.data.getPrevDay()); + } + } + + @Test + void calendarWorkerSettlesFailedVisibleCompletionBeforeDispatchingTheNextPeriod() throws Exception { + try (CalendarRetryFixture fixture = new CalendarRetryFixture()) { + fixture.data.failingForce = 2; + fixture.requestCheck(); + assertEquals(java.util.List.of("DAY:2025-01-02"), fixture.dayIds); + assertEquals(2, fixture.data.getPrevDay()); + assertNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + assertFalse(YamlConfiguration.loadConfiguration(fixture.data.getdFile()) + .getBoolean("TimeTransitions.DAY.Pending")); + fixture.now.set(java.time.Instant.parse("2025-01-03T12:00:00Z")); + fixture.data.failEveryForce = true; + fixture.requestCheck(); + assertEquals(java.util.List.of("DAY:2025-01-02"), fixture.dayIds, + "the next period cannot dispatch while the prior completion remains unsettled"); + assertEquals("DAY:2025-01-02", fixture.data.getData().getString("TimeTransitions.DAY.Id")); + fixture.data.failEveryForce = false; + fixture.data.failingForce = 0; + fixture.requestCheck(); + assertEquals(java.util.List.of("DAY:2025-01-02", "DAY:2025-01-03"), fixture.dayIds, + "recovery must not replay the already completed period"); + assertEquals(java.util.List.of(1, 5), fixture.dayPublicationForces); + assertEquals(3, YamlConfiguration.loadConfiguration(fixture.data.getdFile()).getInt("PrevDay")); + } + } + + private final class CalendarRetryFixture implements AutoCloseable { + private final java.util.concurrent.atomic.AtomicReference calendarThread = + new java.util.concurrent.atomic.AtomicReference<>(); + private final java.util.concurrent.atomic.AtomicReference now = + new java.util.concurrent.atomic.AtomicReference<>(java.time.Instant.parse("2025-01-02T12:00:00Z")); + private final java.util.concurrent.ScheduledExecutorService calendar = + java.util.concurrent.Executors.newSingleThreadScheduledExecutor(task -> { + Thread worker = com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime + .daemonThreads("calendar-publication-retry-test").newThread(task); + calendarThread.set(worker); + return worker; + }); + private final PublicationFailureServerData data; + private final com.bencodez.advancedcore.api.time.TimeChecker checker; + private final java.util.List dayIds = new java.util.ArrayList<>(); + private final java.util.List dayPublicationForces = new java.util.ArrayList<>(); + + private CalendarRetryFixture() { + AdvancedCorePlugin plugin = plugin(); + var options = mock(com.bencodez.advancedcore.AdvancedCoreConfigOptions.class); + when(options.getTimeZone()).thenReturn("UTC"); + when(options.isTimeChangeFailSafeBypass()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(plugin.isEnabled()).thenReturn(true); + var eventManager = mock(org.bukkit.plugin.PluginManager.class); + when(plugin.getServer().getPluginManager()).thenReturn(eventManager); + data = new PublicationFailureServerData(plugin, false); + data.setup(); + when(plugin.getServerDataFile()).thenReturn(data); + data.getData().set("Month", "JANUARY"); + data.getData().set("PrevWeek", com.bencodez.advancedcore.api.time.TimeCalculation.weekNumber( + java.time.LocalDateTime.of(2025, 1, 2, 12, 0), 0, java.util.Locale.getDefault())); + data.getData().set("PrevDay", 1); + data.saveDataChecked(); + data.directoryForces = 0; + org.mockito.Mockito.doAnswer(call -> { + org.bukkit.event.Event event = call.getArgument(0); + assertTrue(event.isAsynchronous()); + assertSame(calendarThread.get(), Thread.currentThread()); + if (event instanceof com.bencodez.advancedcore.api.time.events.DayChangeEvent day) { + dayIds.add(day.getTransition().getId()); + dayPublicationForces.add(data.directoryForces); + } + return null; + }).when(eventManager).callEvent(org.mockito.ArgumentMatchers.any(org.bukkit.event.Event.class)); + checker = new com.bencodez.advancedcore.api.time.TimeChecker(plugin, new java.time.Clock() { + @Override public java.time.ZoneId getZone() { return java.time.ZoneOffset.UTC; } + @Override public java.time.Clock withZone(java.time.ZoneId zone) { return java.time.Clock.fixed(now.get(), zone); } + @Override public java.time.Instant instant() { return now.get(); } + }); + checker.setTimer(calendar); + } + + private void requestCheck() throws Exception { + checker.requestUpdate(); + calendar.submit(() -> { }).get(5, java.util.concurrent.TimeUnit.SECONDS); + } + + @Override public void close() throws Exception { + try { checker.beginShutdown().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); } + finally { + calendar.shutdownNow(); + assertTrue(calendar.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + } + + private static final class LegacySaveOwner extends ServerData { + private int saves; + private LegacySaveOwner(AdvancedCorePlugin plugin) { super(plugin); } + @Override public void saveData() { saves++; } + } + + private static final class PublicationFailureServerData extends ServerData { + private final boolean fallback; + private final IOException forceFailure = new IOException("synthetic directory force failure"); + private int directoryForces; + private int failingForce; + private boolean failEveryForce; + private int failFromForce; + + private PublicationFailureServerData(AdvancedCorePlugin plugin, boolean fallback) { + super(plugin); + this.fallback = fallback; + } + + @Override protected void moveAtomically(Path source, Path target) throws IOException { + if (fallback) throw new AtomicMoveNotSupportedException(source.toString(), target.toString(), "test provider"); + super.moveAtomically(source, target); + } + + @Override protected void forceDirectory(Path directory) throws IOException { + if (++directoryForces == failingForce || failEveryForce + || failFromForce > 0 && directoryForces >= failFromForce) throw forceFailure; + super.forceDirectory(directory); + } + } + + private static final class MoveFailureServerData extends ServerData { + private int moveFailure; + private MoveFailureServerData(AdvancedCorePlugin plugin) { super(plugin); } + @Override protected void moveAtomically(Path source, Path target) throws IOException { + if (moveFailure == 0 || moveFailure == 1) super.moveAtomically(source, target); + if (moveFailure == 3) Files.writeString(target, "Unknown: outcome\n"); + if (moveFailure != 0) throw new IOException("synthetic move failure"); + } + } + @Test void atomicServerDataSavePropagatesReplacementFailure() throws Exception { TestServerData data = serverData(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerShutdownAdmissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerShutdownAdmissionTest.java new file mode 100644 index 0000000000..2125a0141e --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerShutdownAdmissionTest.java @@ -0,0 +1,401 @@ +package com.bencodez.advancedcore.tests.time; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +import java.io.File; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.logging.Logger; + +import org.bukkit.Server; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.event.Event; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.item.FullInventoryHandler; +import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.advancedcore.data.ServerData; + +class TimeCheckerShutdownAdmissionTest { + @TempDir File directory; + + @Test void recoveredFacadeValidationAndBeginShareTheActualPublicationOwner() throws Exception { + try (Fixture fixture = new Fixture(directory)) { + var facade = new PublicationFacade(fixture.plugin, fixture.data); + when(fixture.plugin.getServerDataFile()).thenReturn(facade); + fixture.startCalendarCheck(); + fixture.data.allowValidation.countDown(); + fixture.calendarCheck.get(5, TimeUnit.SECONDS); + assertEquals(2, facade.validations.get()); + assertTrue(facade.recoveryValidatedUnderOwner.get(), + "recovered validation must remain atomic with begin against native publication"); + assertTrue(facade.recoveryBeganUnderOwner.get()); + assertEquals(3, fixture.events.get()); + assertNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + } + } + + private static final class PublicationFacade extends ServerData { + private final ServerData owner; + final AtomicInteger validations = new AtomicInteger(); + final AtomicBoolean recoveryValidatedUnderOwner = new AtomicBoolean(); + final AtomicBoolean recoveryBeganUnderOwner = new AtomicBoolean(); + PublicationFacade(AdvancedCorePlugin plugin, ServerData owner) { super(plugin); this.owner = owner; } + @Override public Object publicationMonitor() { return owner.publicationMonitor(); } + @Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) { + return owner.getPendingTimeChangeTransition(type); + } + @Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState state) { + if (validations.incrementAndGet() == 2) + recoveryValidatedUnderOwner.set(Thread.holdsLock(owner)); + return owner.isPendingTimeChangeTransition(state); + } + @Override public TimeChangeTransitionState beginTimeChangeTransition(TimeType type, String period, String marker) { + recoveryBeganUnderOwner.set(Thread.holdsLock(owner)); + return owner.beginTimeChangeTransition(type, period, marker); + } + @Override public void completeTimeChangeTransition(TimeChangeTransitionState state) { + owner.completeTimeChangeTransition(state); + } + @Override public void failTimeChangeTransition(TimeChangeTransitionState state) { + owner.failTimeChangeTransition(state); + } + } + + @Test void inventorySaveDoesNotBlockShutdownAdmissionOrCompleteItsDrainEarly() throws Exception { + try (Fixture fixture = new Fixture(directory)) { + fixture.startCalendarCheck(); + CompletionStage inventorySave = fixture.startInventorySave(); + fixture.allowValidationBehindSave(); + + CompletionStage firstDrain = fixture.beginShutdownPromptly(); + CompletionStage repeatedDrain = fixture.beginShutdownPromptly(); + assertFalse(firstDrain.toCompletableFuture().isDone()); + assertFalse(repeatedDrain.toCompletableFuture().isDone()); + assertFalse(inventorySave.toCompletableFuture().isDone()); + assertTrue(fixture.checker.isActiveProcessing()); + assertEquals(0, fixture.events.get()); + + fixture.data.releaseCheckedSave.countDown(); + inventorySave.toCompletableFuture().get(5, TimeUnit.SECONDS); + firstDrain.toCompletableFuture().get(5, TimeUnit.SECONDS); + repeatedDrain.toCompletableFuture().get(5, TimeUnit.SECONDS); + fixture.calendarCheck.get(5, TimeUnit.SECONDS); + + assertEquals(3, fixture.events.get(), "already admitted events still finish normally"); + assertFalse(fixture.checker.isActiveProcessing()); + assertNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + YamlConfiguration stored = YamlConfiguration.loadConfiguration(fixture.data.getdFile()); + assertFalse(stored.getBoolean("TimeTransitions.DAY.Pending")); + assertEquals(12, stored.getInt("PrevDay"), "the drain follows the actual durable completion"); + } + } + + @Test void expiredLifecycleDeadlineReturnsBehindAnIndependentSaveAndRetainsStorage() throws Exception { + try (Fixture fixture = new Fixture(directory)) { + fixture.enableUserStorage(); + fixture.data.blockTimestampPublication.set(true); + fixture.startCalendarCheck(); + CompletableFuture unrelatedSave = fixture.startIndependentSave(); + fixture.allowValidationBehindSave(); + BukkitRuntimePlatform platform = spy(new BukkitRuntimePlatform(fixture.plugin)); + // Model the non-blocking Bukkit owner; the actual cleanup and storage stages remain real. + doReturn(false).when(platform).canBlockForPreExecutorShutdown(); + AdvancedCoreRuntime runtime = new AdvancedCoreRuntime(platform, + () -> TimeUnit.SECONDS.toNanos(15)); + + fixture.expectFinalFilePersistence = true; + fixture.caller.submit(() -> runtime.shutdown(0L)).get(2, TimeUnit.SECONDS); + + assertFalse(unrelatedSave.isDone(), "caller return is not an I/O acknowledgement"); + assertFalse(fixture.calendarCheck.isDone(), "the calendar worker still waits for ServerData"); + assertFalse(platform.beforeExecutorShutdownCompletion().toCompletableFuture().isDone()); + assertFalse(fixture.manager.getTimer().isShutdown()); + verify(fixture.nativeOwner, never()).close(); + assertEquals(0, fixture.events.get()); + + // A replacement timer and repeated shutdown cannot revive the retired admission. + ScheduledExecutorService replacement = mock(ScheduledExecutorService.class); + fixture.checker.setTimer(replacement); + fixture.checker.requestUpdate(); + fixture.checker.forceChanged(TimeType.DAY); + fixture.beginShutdownPromptly(); + verify(replacement, never()).execute(any(Runnable.class)); + + fixture.data.releaseCheckedSave.countDown(); + unrelatedSave.get(5, TimeUnit.SECONDS); + fixture.calendarCheck.get(5, TimeUnit.SECONDS); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS); + assertTrue(fixture.manager.getTimer().awaitTermination(5, TimeUnit.SECONDS)); + verify(fixture.nativeOwner, times(1)).close(); + verify(fixture.backend, times(1)).close(); + assertEquals(0, fixture.events.get(), "late validation cannot dispatch after platform cleanup"); + assertTrue(fixture.data.timestampPublicationEntered.await(5, TimeUnit.SECONDS)); + assertFalse(fixture.finalFilePersistence.toCompletableFuture().isDone(), + "storage retirement does not confirm final file publication"); + fixture.data.releaseTimestampPublication.countDown(); + fixture.awaitFinalFilePersistence(); + assertTrue(YamlConfiguration.loadConfiguration(fixture.data.getdFile()) + .getBoolean("TimeTransitions.DAY.Pending"), "cancelled work remains recoverable"); + } + } + + @Test void reloadInvalidatesAReservedRecordWithoutBlockingTheNextCheck() throws Exception { + try (Fixture fixture = new Fixture(directory)) { + fixture.startCalendarCheck(); + fixture.data.getData().set("TimeTransitions.DAY.Pending", false); + fixture.data.saveDataChecked(); + fixture.data.reloadData(); + fixture.data.allowValidation.countDown(); + fixture.calendarCheck.get(5, TimeUnit.SECONDS); + + assertFalse(fixture.checker.isActiveProcessing()); + assertEquals(0, fixture.events.get(), "the stale record must not publish an event"); + fixture.data.beginTimeChangeTransition(TimeType.DAY, "2026-10-13", "13"); + fixture.checker.requestUpdate(); + fixture.calendar.submit(() -> { }).get(5, TimeUnit.SECONDS); + + assertEquals(3, fixture.events.get()); + assertNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + assertEquals(13, YamlConfiguration.loadConfiguration(fixture.data.getdFile()).getInt("PrevDay")); + assertTrue(fixture.beginShutdownPromptly().toCompletableFuture().isDone()); + assertTrue(fixture.beginShutdownPromptly().toCompletableFuture().isDone()); + } + } + + @Test void failedValidationReleasesOnlyItsReservationAndLeavesTheRecordRecoverable() throws Exception { + try (Fixture fixture = new Fixture(directory)) { + fixture.startCalendarCheck(); + fixture.data.failValidation.set(true); + fixture.data.allowValidation.countDown(); + ExecutionException failure = assertThrows(ExecutionException.class, + () -> fixture.calendarCheck.get(5, TimeUnit.SECONDS)); + assertEquals("validation failure", failure.getCause().getMessage()); + + assertFalse(fixture.checker.isActiveProcessing()); + assertEquals(0, fixture.events.get()); + assertNotNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + assertTrue(YamlConfiguration.loadConfiguration(fixture.data.getdFile()) + .getBoolean("TimeTransitions.DAY.Pending")); + fixture.checker.requestUpdate(); + fixture.calendar.submit(() -> { }).get(5, TimeUnit.SECONDS); + + assertEquals(3, fixture.events.get(), "a failed validation must not strand later checks"); + assertNull(fixture.data.getPendingTimeChangeTransition(TimeType.DAY)); + assertTrue(fixture.beginShutdownPromptly().toCompletableFuture().isDone()); + } + } + + private static final class Fixture implements AutoCloseable { + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final AtomicReference calendarThread = new AtomicReference<>(); + final AtomicInteger events = new AtomicInteger(); + final ScheduledExecutorService calendar = Executors.newSingleThreadScheduledExecutor(task -> { + Thread worker = AdvancedCoreRuntime.daemonThreads("calendar-admission-test").newThread(task); + calendarThread.set(worker); + return worker; + }); + final ExecutorService persistence = Executors.newSingleThreadExecutor( + AdvancedCoreRuntime.daemonThreads("checked-save-test")); + final ExecutorService caller = Executors.newSingleThreadExecutor( + AdvancedCoreRuntime.daemonThreads("shutdown-caller-test")); + final GatedServerData data; + final TimeChecker checker; + final FullInventoryHandler inventory; + Future calendarCheck; + UserDataManager manager; + MySQL nativeOwner; + SqlUserBackend backend; + final CompletableFuture finalFilePersistence = new CompletableFuture<>(); + boolean expectFinalFilePersistence; + + Fixture(File directory) { + Server server = mock(Server.class); + PluginManager eventManager = mock(PluginManager.class); + when(plugin.getServer()).thenReturn(server); + when(server.getScheduler()).thenReturn(mock(org.bukkit.scheduler.BukkitScheduler.class)); + when(server.getPluginManager()).thenReturn(eventManager); + when(plugin.getDataFolder()).thenReturn(directory); + when(plugin.getLogger()).thenReturn(Logger.getAnonymousLogger()); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions()).thenReturn(mock(AdvancedCoreConfigOptions.class)); + doAnswer(call -> { + CompletionStage finalFilesStage = call.getArgument(0); + finalFilesStage.whenComplete((ignored, failure) -> { + if (failure == null) finalFilePersistence.complete(null); + else finalFilePersistence.completeExceptionally(failure); + }); + return null; + }).when(plugin).onShutdownComplete(any()); + doAnswer(call -> { + Event event = call.getArgument(0); + assertTrue(event.isAsynchronous()); + assertSame(calendarThread.get(), Thread.currentThread()); + events.incrementAndGet(); + return null; + }).when(eventManager).callEvent(any(Event.class)); + data = new GatedServerData(plugin, calendarThread); + data.setup(); + when(plugin.getServerDataFile()).thenReturn(data); + data.beginTimeChangeTransition(TimeType.DAY, "2026-10-12", "12"); + checker = new TimeChecker(plugin); + checker.setTimer(calendar); + when(plugin.getTimeChecker()).thenReturn(checker); + inventory = new FullInventoryHandler(plugin); + when(plugin.getFullInventoryHandler()).thenReturn(inventory); + } + + void enableUserStorage() { + UserManager users = mock(UserManager.class); + manager = new UserDataManager(plugin); + nativeOwner = mock(MySQL.class); + backend = mock(SqlUserBackend.class); + UserCacheOwner cache = mock(UserCacheOwner.class); + when(cache.cachedUsers()).thenReturn(Set.of()); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, cache)); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, nativeOwner, null)); + } + + void startCalendarCheck() throws Exception { + calendarCheck = calendar.submit(checker::update); + assertTrue(data.pendingRead.await(2, TimeUnit.SECONDS)); + } + + CompletionStage startInventorySave() { + data.blockNextSave.set(true); + return inventory.shutdownAsync(persistence); + } + + CompletableFuture startIndependentSave() { + data.blockNextSave.set(true); + return CompletableFuture.runAsync(data::saveDataChecked, persistence); + } + + void allowValidationBehindSave() throws Exception { + assertTrue(data.checkedSaveEntered.await(2, TimeUnit.SECONDS)); + data.allowValidation.countDown(); + assertTrue(data.validationAttempted.await(2, TimeUnit.SECONDS)); + assertFalse(calendarCheck.isDone()); + } + + CompletionStage beginShutdownPromptly() throws Exception { + return caller.submit(() -> { return checker.beginShutdown(); }).get(2, TimeUnit.SECONDS); + } + + void awaitFinalFilePersistence() throws Exception { + if (expectFinalFilePersistence) finalFilePersistence.get(5, TimeUnit.SECONDS); + } + + @Override public void close() throws Exception { + data.allowValidation.countDown(); + data.releaseCheckedSave.countDown(); + data.releaseTimestampPublication.countDown(); + calendar.shutdownNow(); + persistence.shutdownNow(); + caller.shutdownNow(); + inventory.getTimer().shutdownNow(); + if (manager != null) manager.getTimer().shutdownNow(); + assertTrue(calendar.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(persistence.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(caller.awaitTermination(5, TimeUnit.SECONDS)); + awaitFinalFilePersistence(); + } + } + + private static final class GatedServerData extends ServerData { + final CountDownLatch pendingRead = new CountDownLatch(1); + final CountDownLatch allowValidation = new CountDownLatch(1); + final CountDownLatch validationAttempted = new CountDownLatch(1); + final CountDownLatch checkedSaveEntered = new CountDownLatch(1); + final CountDownLatch releaseCheckedSave = new CountDownLatch(1); + final CountDownLatch timestampPublicationEntered = new CountDownLatch(1); + final CountDownLatch releaseTimestampPublication = new CountDownLatch(1); + final AtomicBoolean blockNextSave = new AtomicBoolean(); + final AtomicBoolean blockTimestampPublication = new AtomicBoolean(); + final AtomicBoolean failValidation = new AtomicBoolean(); + private final AtomicInteger finalMutations = new AtomicInteger(); + private final AtomicBoolean firstRead = new AtomicBoolean(true); + private final AtomicReference calendarThread; + + GatedServerData(AdvancedCorePlugin plugin, AtomicReference calendarThread) { + super(plugin); + this.calendarThread = calendarThread; + } + + @Override public TimeChangeTransitionState getPendingTimeChangeTransition(TimeType type) { + TimeChangeTransitionState pending = super.getPendingTimeChangeTransition(type); + if (pending != null && Thread.currentThread() == calendarThread.get() + && firstRead.compareAndSet(true, false)) { + // The real read has released ServerData; a save can now acquire its monitor. + pendingRead.countDown(); + await(allowValidation); + } + return pending; + } + + @Override public boolean isPendingTimeChangeTransition(TimeChangeTransitionState transition) { + validationAttempted.countDown(); + if (failValidation.compareAndSet(true, false)) throw new IllegalStateException("validation failure"); + return super.isPendingTimeChangeTransition(transition); + } + + @Override public CompletionStage mutateFinalData( + java.util.function.Consumer mutation) { + // The platform submits inventory first, then timestamp. Gate the latter + // outside the YAML monitor so storage retirement can finish independently. + if (finalMutations.incrementAndGet() == 2 && blockTimestampPublication.get()) { + timestampPublicationEntered.countDown(); + await(releaseTimestampPublication); + } + return super.mutateFinalData(mutation); + } + + @Override protected synchronized void saveTimeChangeTransitionData() { + if (blockNextSave.compareAndSet(true, false)) { + checkedSaveEntered.countDown(); + await(releaseCheckedSave); + } + super.saveTimeChangeTransitionData(); + } + + private static void await(CountDownLatch latch) { + try { latch.await(); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("fixture interrupted", interrupted); + } + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerTest.java index 90570c4fbe..302916037f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCheckerTest.java @@ -63,6 +63,7 @@ public void setUp() { plugin = mock(AdvancedCorePlugin.class); options = mock(AdvancedCoreConfigOptions.class); serverDataFile = mock(ServerData.class); + when(serverDataFile.publicationMonitor()).thenCallRealMethod(); // 3) stub plugin getters when(plugin.getOptions()).thenReturn(options); @@ -865,10 +866,35 @@ public void pendingTransitionIsRecoveredWithItsOriginalIdBeforeClockDetection() assertNotNull(observed.get()); assertEquals("DAY:2025-01-02", observed.get().getId()); - verify(serverDataFile, Mockito.never()).beginTimeChangeTransition(any(), anyString(), anyString()); + verify(serverDataFile).beginTimeChangeTransition(TimeType.DAY, transition.periodKey(), transition.markerValue()); verify(serverDataFile).completeTimeChangeTransition(transition); } + @Test + public void reloadRequestDispatchesRealAsyncDateEventsOnCalendarWorker() throws Exception { + TimeChangeTransitionState transition = transitionState(); + PluginManager manager = configureDetectedDay(transition); + when(plugin.isEnabled()).thenReturn(true); + Thread owner = Thread.currentThread(); + java.util.concurrent.atomic.AtomicInteger delivered = new java.util.concurrent.atomic.AtomicInteger(); + Mockito.doAnswer(call -> { + Event event = call.getArgument(0); + if (!event.isAsynchronous() || Thread.currentThread() == owner) + throw new IllegalStateException("Date event dispatched from server owner"); + delivered.incrementAndGet(); return null; + }).when(manager).callEvent(any(Event.class)); + TimeChecker checker = new TimeChecker(plugin, + Clock.fixed(Instant.parse("2025-01-02T12:00:00Z"), ZoneOffset.UTC)); + ScheduledExecutorService timer = Executors.newSingleThreadScheduledExecutor(); checker.setTimer(timer); + try { + checker.requestUpdate(); timer.submit(() -> {}).get(5, TimeUnit.SECONDS); + assertEquals(3, delivered.get()); + verify(serverDataFile).completeTimeChangeTransition(transition); + verify(serverDataFile, Mockito.never()).failTimeChangeTransition(any()); + checker.beginShutdown().toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { timer.shutdownNow(); assertTrue(timer.awaitTermination(5, TimeUnit.SECONDS)); } + } + private PluginManager configureDetectedDay(TimeChangeTransitionState transition) { Server server = mock(Server.class); PluginManager pluginManager = mock(PluginManager.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index 79f4466d8d..61153cb5a9 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java @@ -98,7 +98,8 @@ public void setUp() { userManager = mock(UserManager.class); dataManager = mock(UserDataManager.class); data = mock(UserData.class); - rewardHandler = mock(RewardHandler.class); + rewardHandler = mock(RewardHandler.class, org.mockito.Mockito.withSettings().useConstructor(plugin)); + org.mockito.Mockito.doCallRealMethod().when(rewardHandler).admitPersistedReplayCheck(); delayedTimer = mock(ScheduledExecutorService.class); MySQL mysql = mock(MySQL.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/PreStorageHookAdmissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/PreStorageHookAdmissionTest.java new file mode 100644 index 0000000000..fdbe8e8f24 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/PreStorageHookAdmissionTest.java @@ -0,0 +1,228 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CountDownLatch; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class PreStorageHookAdmissionTest { + @Test void successfulHookInsideAdmissionFlushesBeforeNativeClose() throws Exception { + completeInsideReadAdmission("success"); + } + @Test void failedHookInsideAdmissionFlushesBeforeReportingFailure() throws Exception { + completeInsideReadAdmission("failure"); + } + @Test void cancelledHookInsideAdmissionStillRetiresStorage() throws Exception { + completeInsideReadAdmission("cancelled"); + } + + @Test void successfulHookAfterWatchdogStillFlushesBeforeNativeClose() throws Exception { + completeInsideReadAdmission("success", true); + } + @Test void failedHookAfterWatchdogStillFlushesBeforeReportingFailure() throws Exception { + completeInsideReadAdmission("failure", true); + } + @Test void cancelledHookAfterWatchdogStillRetiresStorage() throws Exception { + completeInsideReadAdmission("cancelled", true); + } + + private void completeInsideReadAdmission(String mode) throws Exception { + completeInsideReadAdmission(mode, false); + } + + private void completeInsideReadAdmission(String mode, boolean runWatchdog) throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL nativeOwner = mock(MySQL.class); + SqlUserBackend backend = mock(SqlUserBackend.class); + SqlUserStorage storage = mock(SqlUserStorage.class); + UserCacheOwner cache = mock(UserCacheOwner.class); + UUID uuid = UUID.randomUUID(); + when(cache.cachedUsers()).thenReturn(Set.of(uuid)); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + when(backend.isOpen()).thenReturn(true); + when(backend.user(uuid)).thenReturn(storage); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, nativeOwner, null)); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + var hook = new CompletableFuture(); + var preparationFailure = new IllegalStateException("preparation failed"); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + manager.bindSharedRuntime(runtime); + when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getAnonymousLogger()); + BukkitRuntimePlatform platform = spy(new BukkitRuntimePlatform(plugin)); + var timedOut = new CountDownLatch(1); + if (runWatchdog) { + doReturn(false).when(platform).canBlockForPreExecutorShutdown(); + doReturn(20L).when(platform).deferredShutdownTimeoutMillis(); + doAnswer(call -> { timedOut.countDown(); return null; }) + .when(platform).cleanupFailed(eq("pre-executor shutdown"), + any(java.util.concurrent.TimeoutException.class)); + } + Runnable shutdown = platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("user storage")).findFirst().orElseThrow().action(); + try { + if (runWatchdog) { + new AdvancedCoreRuntime(platform).shutdown(); + assertTrue(timedOut.await(2, TimeUnit.SECONDS)); + assertFalse(manager.getTimer().isShutdown(), "pending consumer preparation retains admission"); + verify(nativeOwner, never()).close(); + assertFalse(runtime.isRetiring()); + } else shutdown.run(); + var retirement = platform.beforeExecutorShutdownCompletion().toCompletableFuture(); + manager.getTimer().submit(() -> runtime.withStorageReadAdmission(() -> { + if (mode.equals("success")) hook.complete(null); + else if (mode.equals("failure")) hook.completeExceptionally(preparationFailure); + else hook.cancel(false); + assertFalse(runtime.isRetiring(), "retirement must wait for this callback to unwind"); + assertFalse(retirement.isDone()); + verify(nativeOwner, never()).close(); + // The callback still owns admission and can finish accepted work. + runtime.withStorageReadAdmission(() -> null); + return null; + })).get(5, TimeUnit.SECONDS); + + if (mode.equals("success")) retirement.get(5, TimeUnit.SECONDS); + else { + assertThrows(java.util.concurrent.ExecutionException.class, () -> retirement.get(5, TimeUnit.SECONDS)); + CompletionException reported = assertThrows(CompletionException.class, retirement::join); + if (mode.equals("failure")) assertSame(preparationFailure, reported.getCause()); + else assertInstanceOf(java.util.concurrent.CancellationException.class, reported.getCause()); + } + var order = inOrder(cache, backend, nativeOwner); + order.verify(cache).beginRetirement(); + order.verify(cache).flush(uuid, UserStorage.MYSQL, storage); + order.verify(cache).clearAfterFlush(); + order.verify(cache).shutdown(); + order.verify(backend).close(); + order.verify(nativeOwner).close(); + shutdown.run(); + verify(backend, times(1)).close(); + verify(nativeOwner, times(1)).close(); + assertTrue(runtime.isClosed()); + assertFalse(manager.hasSharedRuntimeLifecycle()); + assertNull(manager.getLastDeferredStorageFailure()); + if (runWatchdog) assertTrue(manager.getTimer().awaitTermination(5, TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test void rejectedContinuationDoesNotDetachRuntime() { + rejectContinuation(false); + } + @Test void rejectedContinuationPreservesPreparationFailure() { + rejectContinuation(true); + } + + private void rejectContinuation(boolean failedPreparation) { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + SqlUserBackend backend = mock(SqlUserBackend.class); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, mock(UserCacheOwner.class)); + manager.bindSharedRuntime(runtime); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + var hook = new CompletableFuture(); + var preparationFailure = new IllegalStateException("preparation failed"); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + try { + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + manager.getTimer().shutdownNow(); + if (failedPreparation) hook.completeExceptionally(preparationFailure); else hook.complete(null); + CompletionException reported = assertThrows(CompletionException.class, + () -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().join()); + if (failedPreparation) { + assertSame(preparationFailure, reported.getCause()); + assertEquals(1, reported.getSuppressed().length); + assertInstanceOf(RejectedExecutionException.class, reported.getSuppressed()[0]); + } else assertInstanceOf(RejectedExecutionException.class, reported.getCause()); + assertTrue(manager.hasSharedRuntime()); + assertFalse(runtime.isRetiring()); + verify(backend, never()).close(); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test void completedHookDoesNotSignalPreparationUntilRetirementIsAdmitted() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + var admission = mock(java.util.concurrent.ScheduledExecutorService.class); + var entered = new CountDownLatch(1); + var release = new CountDownLatch(1); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + var hook = new CompletableFuture(); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + BukkitRuntimePlatform platform = spy(new BukkitRuntimePlatform(plugin)); + doReturn(admission).when(platform).getUserStorageTimer(); + doAnswer(call -> { + entered.countDown(); + assertTrue(release.await(2, TimeUnit.SECONDS)); + manager.getTimer().execute(call.getArgument(0, Runnable.class)); + return null; + }).when(admission).execute(any(Runnable.class)); + var callback = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + var preparation = platform.storageRetirementPreparationCompletion().toCompletableFuture(); + var completing = callback.submit(() -> hook.complete(null)); + assertTrue(entered.await(2, TimeUnit.SECONDS)); + assertTrue(hook.isDone(), "CompletableFuture becomes done before its callback unwinds"); + assertFalse(preparation.isDone(), "watchdog must retain admission during callback dispatch"); + assertFalse(manager.getTimer().isShutdown()); + release.countDown(); + completing.get(2, TimeUnit.SECONDS); + preparation.get(2, TimeUnit.SECONDS); + platform.beforeExecutorShutdownCompletion().toCompletableFuture().get(2, TimeUnit.SECONDS); + } finally { + release.countDown(); + callback.shutdownNow(); + manager.getTimer().shutdownNow(); + } + } + + @Test void delayedHookWithNoUserStorageRemainsSupported() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var hook = new CompletableFuture(); + when(plugin.onBeforeStorageShutdown()).thenReturn(hook); + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + platform.beforeExecutorShutdown().stream().filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + hook.complete(null); + assertDoesNotThrow(() -> platform.beforeExecutorShutdownCompletion().toCompletableFuture().join()); + // The admitted identity is a non-creating snapshot, even with loading disabled. + verify(plugin).getLoadedUserManager(); + verify(plugin, never()).getUserManager(); + verify(plugin, never()).getNativeUserStorageOwner(); + verify(plugin, never()).getMysql(); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java index a6d48904ac..339631ed15 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java @@ -468,6 +468,7 @@ private static final class Fixture { final MemoryBackend first = new MemoryBackend(UserStorage.SQLITE); final BukkitUserCacheOwner owner; Fixture() { + when(manager.isPlatformOwnedThread()).thenCallRealMethod(); when(manager.getPlugin()).thenReturn(plugin); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(manager.getUserDataCache()).thenReturn(caches); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java index 401be45e28..9894d4b957 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java @@ -214,6 +214,65 @@ void bulkOperationHoldsLifecycleAdmissionUntilTheOldProviderIsNoLongerInUse() th } } + @Test + void queuedNotificationIsFencedWhenRetirementIsAdmittedBeforeItsWorkerRuns() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, new RoutingCacheOwner(manager)); + manager.bindSharedRuntime(runtime); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var delivered = new java.util.concurrent.atomic.AtomicInteger(); + manager.getTimer().execute(() -> { + entered.countDown(); + try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new RuntimeException(failure); } + }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + manager.dispatchSharedUserDataNotification(delivered::incrementAndGet); + CompletionStage retirement = manager.closeSharedRuntimeAsyncCompletion(() -> {}); + assertFalse(retirement.toCompletableFuture().isDone()); + release.countDown(); + retirement.toCompletableFuture().get(5, TimeUnit.SECONDS); + manager.getTimer().submit(() -> {}).get(5, TimeUnit.SECONDS); + assertEquals(0, delivered.get()); + assertNull(manager.getLastDeferredStorageFailure()); + verify(backend).close(); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + + @Test + void alreadyAdmittedNotificationFinishesItsNestedStorageAccessBeforeRetirement() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, new RoutingCacheOwner(manager)); + manager.bindSharedRuntime(runtime); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var delivered = new java.util.concurrent.atomic.AtomicInteger(); + try { + manager.dispatchSharedUserDataNotification(() -> { + entered.countDown(); + try { assertTrue(release.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new RuntimeException(failure); } + runtime.withStorageReadAdmission(() -> { delivered.incrementAndGet(); return null; }); + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + CompletionStage retirement = manager.closeSharedRuntimeAsyncCompletion(() -> {}); + assertFalse(retirement.toCompletableFuture().isDone()); + release.countDown(); + retirement.toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(1, delivered.get()); + assertNull(manager.getLastDeferredStorageFailure()); + verify(backend).close(); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void sharedBackendReplacementCompletesOnTheManagerWorkerWithoutBlockingTheCaller() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SpigotShutdownStorageWorkerTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SpigotShutdownStorageWorkerTest.java new file mode 100644 index 0000000000..6c91d844b7 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SpigotShutdownStorageWorkerTest.java @@ -0,0 +1,82 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class SpigotShutdownStorageWorkerTest { + @Test void actualStorageWorkerCanRetireWhileSpigotReportsEveryThreadPrimary() throws Exception { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + SqlUserBackend backend = mock(SqlUserBackend.class); + Server server = mock(Server.class); + BukkitUserCacheOwner owner = new BukkitUserCacheOwner(manager); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + try { + manager.getTimer().submit(() -> { + // Mockito's static scope is thread-local: install it on the real storage worker. + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertTrue(Bukkit.isPrimaryThread()); + assertFalse(manager.isPlatformOwnedThread()); + runtime.closeAsync(Runnable::run).toCompletableFuture().join(); + assertTrue(runtime.isClosed()); + } + }).get(5, TimeUnit.SECONDS); + verify(backend).close(); + assertNull(manager.getLastDeferredStorageFailure()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void primaryCallerStillCannotCloseStorageSynchronously() { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + SqlUserBackend backend = mock(SqlUserBackend.class); + Server server = mock(Server.class); + BukkitUserCacheOwner owner = new BukkitUserCacheOwner(manager); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertTrue(manager.isPlatformOwnedThread()); + assertThrows(IllegalStateException.class, runtime::close); + verify(backend, never()).close(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void matchingWorkerNameDoesNotBypassPlatformGuard() throws Exception { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + Server server = mock(Server.class); + BukkitUserCacheOwner owner = new BukkitUserCacheOwner(manager); + String name = manager.getTimer().submit(() -> Thread.currentThread().getName()).get(5, TimeUnit.SECONDS); + var external = Executors.newSingleThreadExecutor(task -> new Thread(task, name)); + try { + external.submit(() -> { + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertTrue(manager.isPlatformOwnedThread()); + assertThrows(IllegalStateException.class, owner::requireBlockingAllowed); + } + }).get(5, TimeUnit.SECONDS); + } finally { + external.shutdownNow(); + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java index 4e6bebbf5d..cc9b86fa58 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/presence/AdvancedCorePlayerPresenceTest.java @@ -64,6 +64,25 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception .getLoaded().getDeclaredConstructor().newInstance(); } + private final java.util.List plugins = new java.util.ArrayList<>(); + + private AdvancedCorePlugin lifecyclePlugin() { + var plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + try { + Field lock = AdvancedCorePlugin.class.getDeclaredField("lifecycleLock"); + lock.setAccessible(true); lock.set(plugin, new Object()); + } catch (ReflectiveOperationException failure) { throw new AssertionError(failure); } + doReturn(temporary.toFile()).when(plugin).getDataFolder(); + doReturn(mock(java.util.logging.Logger.class)).when(plugin).getLogger(); + plugins.add(plugin); + return plugin; + } + + @org.junit.jupiter.api.AfterEach void releaseFixtureOwnership() { + for (var plugin : plugins) plugin.releaseServerDataOwnershipAfter( + java.util.concurrent.CompletableFuture.completedFuture(null)); + } + @Test void olderApiCapturesActualPlayerStorageWithoutWorldDirectoryAssumptions() throws Exception { Path history = Files.createDirectory(temporary.resolve("legacy-playerdata")); Server server = (Server) new ByteBuddy().subclass(Object.class).implement(Server.class) @@ -77,11 +96,11 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception assertEquals(PlayerPresence.NEVER_JOINED, new BukkitPlayerPresenceLookup(history).check(USER)); } - @Test void actualEnableRegistersJoinEvidenceBeforePublishingAndDisableRetiresIt() throws Exception { + @Test void actualEnableRegistersJoinEvidenceAndFailedStartupRetiresIt() throws Exception { Path level = Files.createDirectory(temporary.resolve("runtime-level")); Path history = Files.createDirectory(level.resolve("playerdata")); Server server = levelServer(level); - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = lifecyclePlugin(); doReturn(server).when(plugin).getServer(); var worker = mock(java.util.concurrent.ScheduledExecutorService.class); var tasks = new ArrayDeque(); @@ -94,16 +113,22 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); return null; }).when(plugin).registerEvents(any(BukkitPlayerPresenceLookup.class)); - doThrow(new IllegalStateException("stop after presence initialization")).when(plugin).onPreLoad(); - try (var runtime = mockStatic(AdvancedCoreLifecycle.class)) { + doAnswer(call -> { + var result = plugin.checkPlayerPresenceAsync(USER); + assertFalse(result.toCompletableFuture().isDone()); + while (!tasks.isEmpty()) tasks.remove().run(); + assertEquals(PlayerPresence.NEVER_JOINED, value(result)); + throw new IllegalStateException("stop after presence initialization"); + }).when(plugin).onPreLoad(); + try (var runtime = mockStatic(AdvancedCoreLifecycle.class); + var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); assertThrows(IllegalStateException.class, plugin::onEnable); + plugin.onDisable(); + assertEquals(1, lifecycle.constructed().size(), "failed startup cleanup and repeated disable run once"); } assertNotNull(registered.get()); - var result = plugin.checkPlayerPresenceAsync(USER); - assertFalse(result.toCompletableFuture().isDone()); while (!tasks.isEmpty()) tasks.remove().run(); - assertEquals(PlayerPresence.NEVER_JOINED, value(result)); - try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); assertEquals(PlayerPresence.UNKNOWN, registered.get().check(USER)); verify(worker, times(2)).execute(any(Runnable.class)); verifyNoMoreInteractions(worker); try (var files = Files.list(history)) { assertEquals(0, files.count()); } @@ -113,7 +138,7 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception Path level = Files.createDirectory(temporary.resolve("saving-disabled")); Path history = Files.createDirectory(level.resolve("playerdata")); Server server = levelServer(level, true); - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = lifecyclePlugin(); doReturn(server).when(plugin).getServer(); var worker = mock(java.util.concurrent.ScheduledExecutorService.class); var tasks = new ArrayDeque(); doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(worker).execute(any(Runnable.class)); @@ -121,19 +146,22 @@ private Server levelServer(Path level, boolean savingDisabled) throws Exception var registered = new java.util.concurrent.atomic.AtomicReference(); doAnswer(call -> { registered.set(call.getArgument(0)); return null; }) .when(plugin).registerEvents(any(BukkitPlayerPresenceLookup.class)); - doThrow(new IllegalStateException("stop after presence initialization")).when(plugin).onPreLoad(); - try (var runtime = mockStatic(AdvancedCoreLifecycle.class)) { + doAnswer(call -> { + var result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); + assertEquals(PlayerPresence.UNKNOWN, value(result), "No saved record cannot prove absence when saving is disabled"); + var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(USER); + registered.get().onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); + result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); + assertEquals(PlayerPresence.JOINED, value(result)); + throw new IllegalStateException("stop after presence initialization"); + }).when(plugin).onPreLoad(); + try (var runtime = mockStatic(AdvancedCoreLifecycle.class); + var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { runtime.when(() -> AdvancedCoreLifecycle.createRuntimeExecutors(plugin)).thenReturn(executors); assertThrows(IllegalStateException.class, plugin::onEnable); } - var result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); - assertEquals(PlayerPresence.UNKNOWN, value(result), "No saved record cannot prove absence when saving is disabled"); - var player = mock(org.bukkit.entity.Player.class); when(player.getUniqueId()).thenReturn(USER); - registered.get().onJoin(new org.bukkit.event.player.PlayerJoinEvent(player, null)); - result = plugin.checkPlayerPresenceAsync(USER); while (!tasks.isEmpty()) tasks.remove().run(); - assertEquals(PlayerPresence.JOINED, value(result)); + assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); try (var files = Files.list(history)) { assertEquals(0, files.count()); } - try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { plugin.onDisable(); } } private static void attach(AdvancedCorePlugin plugin, PlayerPresenceService service) throws Exception { @@ -146,7 +174,7 @@ private static PlayerPresence value(CompletionStage result) { } @Test void partialStartupHasUnknownHistoryInsteadOfCreatingAnIdentity() { - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = lifecyclePlugin(); assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(null))); verify(plugin, never()).getServer(); @@ -155,7 +183,7 @@ private static PlayerPresence value(CompletionStage result) { @Test void publicApiKeepsUuidLookupAndHistoryOffRequestingThread() throws Exception { var worker = new ArrayDeque(); - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = lifecyclePlugin(); try (var service = new PlayerPresenceService(worker::add, id -> { assertEquals(USER, id); return PlayerPresence.JOINED; })) { @@ -171,7 +199,7 @@ private static PlayerPresence value(CompletionStage result) { @Test void actualDisableRetiresRequestsBeforeExistingLifecycleRuns() throws Exception { var worker = new ArrayDeque(); - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = lifecyclePlugin(); var reads = new java.util.concurrent.atomic.AtomicInteger(); var service = new PlayerPresenceService(worker::add, id -> { reads.incrementAndGet(); return PlayerPresence.JOINED; @@ -183,16 +211,29 @@ private static PlayerPresence value(CompletionStage result) { assertEquals(PlayerPresence.UNKNOWN, value(pending)); assertEquals(PlayerPresence.UNKNOWN, value(plugin.checkPlayerPresenceAsync(USER))); return null; - }).when(instance).shutdown())) { + }).when(instance).shutdown(anyLong()))) { plugin.onDisable(); assertEquals(1, lifecycle.constructed().size()); - verify(lifecycle.constructed().getFirst()).shutdown(); + verify(lifecycle.constructed().getFirst()).shutdown(anyLong()); } worker.remove().run(); assertEquals(0, reads.get()); assertEquals(PlayerPresence.UNKNOWN, value(pending)); } + @Test void presenceCleanupFailureStillRunsCoreCleanupOnce() throws Exception { + var plugin = lifecyclePlugin(); + var service = mock(PlayerPresenceService.class); + attach(plugin, service); + doThrow(new IllegalStateException("synthetic cleanup failure")).when(service).close(); + try (var lifecycle = mockConstruction(AdvancedCoreLifecycle.class)) { + assertThrows(IllegalStateException.class, plugin::onDisable); + plugin.onDisable(); + assertEquals(1, lifecycle.constructed().size()); + verify(lifecycle.constructed().getFirst()).shutdown(anyLong()); + } + } + @Test void completeCapturedScopeAndPublicApiReadHistoryOnActualWorker() throws Exception { Path levelDirectory = Files.createDirectory(temporary.resolve("server-level")); Path worldDirectory = Files.createDirectory(temporary.resolve("dimension")); @@ -217,7 +258,7 @@ private static PlayerPresence value(CompletionStage result) { long requester = Thread.currentThread().threadId(); AtomicLong historyThread = new AtomicLong(-1); var actualLookup = new BukkitPlayerPresenceLookup(captured); - AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCorePlugin plugin = lifecyclePlugin(); try (var worker = Executors.newSingleThreadExecutor(); var service = new PlayerPresenceService(worker, id -> { historyThread.set(Thread.currentThread().threadId()); return actualLookup.check(id); diff --git a/README.md b/README.md index b4bbf561ee..c09579e199 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,45 @@ API used in the plugins developed by BenCodez, can be used in any project. Versions: LATEST - latest stable release Check out all tags [on the releases tab](https://github.com/BenCodez/AdvancedCore/tags). + + +Calendar checks requested by reload run on the existing background calendar timer, +so asynchronous date events are not called inline from a Bukkit or Folia owner. +Repeated requests coalesce while one check is queued or running. Shutdown waits +for admitted checks; queued callbacks skip a retired checker or replaced timer. +Normal periodic checks remain unchanged. This does not move the remaining reload +file reads off the caller, or make reload completion await all date-event listeners. + +When an asynchronous `onBeforeStorageShutdown()` stage settles, storage retirement +is queued on the existing user storage worker. The completing callback can finish +its admitted storage work before the shared cache flush and native provider close. +Already completed default hooks retain their existing retirement-stage behavior. +A rejected continuation reports failure and leaves the shared runtime attached; +it does not report a successful flush. Failed or cancelled preparation still +retires accepted writes, with the preparation failure retained. No new executor, +configuration, or storage format is introduced. + +Shutdown captures user-storage admission and its manager before waiting for the +calendar or consumer hook. Deferred unload can disable user loading or replace +the public manager reference without skipping that admitted manager's flush and +cleanup. The native provider is still resolved after shared retirement so a +provider replacement completed by admitted work closes its current owner. + +If consumer preparation exceeds the default five-second shutdown watchdog, the +watchdog reports it and retains the existing daemon storage worker and native +provider. Preparation is acknowledged only after its callback has submitted the +final retirement task. A future becoming complete alone is insufficient because +its callbacks can still be running. After that acknowledgement, a stalled storage +retirement still receives the bounded watchdog and worker grace; terminal native +cleanup waits for the actual storage worker to terminate. + +The first watchdog fences an outstanding calendar transition and immediately +interrupts its held calendar executor, even while consumer preparation remains +pending. Interruptible calendar listeners can therefore retire without waiting +for that hook. Storage admission and its native provider remain available for the +eventual checkpoint; interrupting a listener does not prove its work persisted. + +A consumer hook that never settles retains its storage owner until JVM exit. +This does not block the server thread or keep the JVM alive, but an abrupt exit +cannot guarantee the pending checkpoint was persisted. Consumers must complete, +fail, or cancel their preparation stage. No new executor or polling loop is added. diff --git a/docs/shared-runtime-foundation.md b/docs/shared-runtime-foundation.md index 9db6cbb6cc..b864ef6136 100644 --- a/docs/shared-runtime-foundation.md +++ b/docs/shared-runtime-foundation.md @@ -19,16 +19,167 @@ parallel runtime with different caches or a second set of background tasks. - General, login and inventory work retain separate single-thread executors. - BukkitScheduler/Folia behavior is unchanged; game/entity/region scheduling is not redirected to these background executors. -- JavaScript cleanup, MySQL closing and server timestamp happen before executor - shutdown, as before. This extraction does not redesign database flushing. -- Executor grace periods remain 2/2/2/1 seconds, followed by reward shutdown, +- JavaScript cleanup still happens before executor shutdown. Native providers + close only after their storage worker and admitted preparation settle. +- Executor grace periods remain at most 2/2/2/1 seconds, followed by reward shutdown, forced executor shutdown and the existing unload/cache/inventory/integration cleanup order. The time-checker timer is captured at the same point. - Individual component failures retain the warning/debug callback behavior and do not skip later components. Interrupted waits restore the interrupt flag. -- Lifecycle calls remain serialized by the owning platform. Hook execution is - synchronous; the executor grace periods are not a global bound on all cleanup - callbacks. No stronger transactional or idempotency guarantee is claimed. +- Lifecycle calls remain serialized by the owning platform. Consumer hooks and + game/integration cleanup retain their lifecycle-thread execution. + +## Shared shutdown deadline + +All owned shutdown waits share one monotonic 15-second deadline captured at entry +into `AdvancedCorePlugin.onDisable()`. Pre-storage preparation, executor grace, +delayed rewards, forced-worker grace, and deferred watchdogs consume the same +remaining budget; a late preparation completion cannot start another grace +period. The public `RewardHandler.shutdown()` keeps its ten-second maximum for +standalone callers, while lifecycle shutdown supplies only its remaining budget. + +Final inventory, server timestamp, and timed-permission mutations are admitted in +order on one bounded daemon persistence worker. Each publication stage settles +before its successor snapshot starts, including asynchronous adapters. Handler +admission is fenced immediately, and the worker remains available for ordered +successors until all final stages settle. `ServerData.mutateFinalData` +returns the actual file owner's publication stage. Native and completion-aware +final mutations are staged in a private YAML candidate. Native live YAML retains +a replacement that has become visible even if its directory sync then fails; +visibility alone does not acknowledge its reward receipt. +Native ServerData forces that candidate on the shutdown worker; an ordered +adapter serializes under its own YAML monitor and confirms its existing publisher. +Later unrelated saves cannot publish a failed final overflow mutation. Ordered +snapshots carry only successfully published final changes forward and preserve +newer explicit edits, including an edit that writes the same previous value. +No caller waits for publication while holding the YAML owner's monitor. Legacy +live read/mutate/save transactions use `ServerData.publicationMonitor()`, whose +native default is the file object itself. A facade must return its backing +owner's stable monitor and delegate YAML access, mutations and publication to +that same owner; overriding the monitor alone does not reroute inherited native +methods. Overflow-section replacement and recovered time-transition validation +use this contract so ordinary or final whole-file snapshots cannot interleave +with those compound live operations. This lock does not turn a queued or void +save into a durable acknowledgement and must never be held while awaiting an +asynchronous publication stage. + +Legacy +void `saveData` overrides retain the established live-YAML mutation followed by +one save callback, but cannot claim confirmed publication without overriding the +additive completion contract; they report an unsupported confirmation. A legacy +callback failure remains observable, with no second mutation or save invocation. +Its live changes retain the old non-transactional behavior; candidate isolation +remains specific to native and completion-aware owners. For a known visible +native replacement, one immediate retry may settle only its retained directory +sync and cleanup, without repeating the mutation or move. Confirmation settles +the original publication successfully. If that retry fails, its stage fails and +the owner rejects subsequent saves, final mutations, reload and setup; later +cleanup cannot silently commit unacknowledged overflow. This fence does not +remove the separate file/receipt crash window or promise replay safety after an +uncertain restart. Failures before replacement retain their existing recovery +behavior. Inventory persistence gates user-storage +retirement and cannot acknowledge reserved replay overflow before its actual +save succeeds. The public synchronous handler shutdown methods remain available. +The shutdown-completion hook follows submission of final file saves; it is not a +durability acknowledgement. Its additive `onShutdownComplete(finalFilesStage)` +overload receives completion of all final file publications and invokes the +existing zero-argument hook on the same lifecycle thread by default. A consumer +retains its file owner until both pre-storage preparation and final-file stages +settle, including failure, then closes that owner. Worker termination must not be +part of either stage, which would create a completion/termination cycle. + +Owned background executors and shutdown continuations use daemon threads so an +interrupt-ignoring JDBC call or filesystem operation cannot retain the JVM. A +timeout is reported while the actual stage stays pending. The native storage +owner remains open until its executing storage worker ends; accepted final file +saves are not discarded to meet the deadline. Preparation that settles after +or just before the deadline still admits its final checkpoint and retains the +daemon worker until that accepted retirement actually settles, without renewing +any caller wait. The runtime distinguishes an accepted native retirement from a +pending time transition or consumer hook; its watchdog cannot discard that +retirement's queued final task. +Durable completion can outlive plugin disable. While admitted shutdown work is +pending, a JVM hook registered with native file admission during startup gives +its existing owners the remainder of the same 15-second deadline during orderly +process exit, including disable inside an already-running host shutdown hook. +The hook performs no file writes or Bukkit calls and does not renew the deadline. +It waits at most 15 seconds from process exit as well, even if the host has not yet +started plugin disable. Successful ownership release or the disable deadline +removes it during a reload; +failed publication still retains the original failure and native admission fence. +An exit after the deadline, a hard process kill, or failure to register the hook +can still lose unfinished work. A timeout never means persistence succeeded. + +The 15-second limit bounds AdvancedCore's waits. Synchronous consumer methods +(`onBeforeStorageShutdown`, `onUnLoad`, `onShutdownComplete`) and third-party +Bukkit/integration cleanup must return promptly: Java cannot safely preempt them +on the lifecycle thread. The temporary process hook waits only for already-owned +shutdown stages and never scans threads or calls other plugins. AdvancedCore +cannot guarantee a total server-stop time when another plugin or one of these +callbacks blocks. + +Consumer `onBeforeStorageShutdown()` stages settle before shared user storage +retirement begins. A thrown exception, exceptional completion, or cancellation +still starts shared-cache retirement and waits for that retirement to settle. +The preparation failure remains the reported cause; a storage failure is retained +as a suppressed failure. Successful default hooks preserve the existing retirement +stage identity. A failing hook does not bypass accepted user-data flushing. + +Delayed-reward admission closes before storage preparation. The additive +`RewardHandler.beginShutdown()` stage reports actual delayed-worker termination; +the existing bounded shutdown methods still cancel queued timers and interrupt +the worker when their wait ends. An interruption request does not release native +storage while an admitted check continues running. After that worker exits, a +manager-worker barrier includes checks it deferred under Spigot/Folia thread +classification and snapshots the existing per-user persisted replay tails. +Those tails include claim/checkpoint acknowledgement or restore callbacks that +can outlive timer execution. Final storage retirement is queued only after those +callbacks finish, so callback-held storage admission can unwind before the final +writer runs. Failed or cancelled preparation and time retirement retain this +ordering and still report their failure. This uses the existing replay owner; +it adds no reward engine or universal delivery guarantee. + +## Native ServerData successor admission + +Enable acquires native `ServerData.yml` admission before `onPreLoad()` and the +native load hook. Calling `loadHook()` directly acquires the same admission before +constructing the file owner. A successor using the same normalized absolute +platform data path fails immediately while its predecessor owns admission; it +does not wait for the predecessor or canonicalize/read the filesystem on that +admission path. Retrying enable after successful predecessor retirement is safe. +Repeated disable calls cannot submit new final writes after admission is released, +and a completed shutdown allows the same instance to acquire a fresh generation +where its existing consumer lifecycle supports re-enable. + +Shaded copies share a bounded JVM Properties namespace with at most 256 opaque +String tokens. No plugin, classloader, future or callback is stored there. Each +owner releases from the exact Properties object into which it acquired its token. +The key uses a digest of the lexically normalized platform path, without file +contents. Installed plugins must preserve the JVM Properties ownership entries +and use the same platform data path; alias paths through symlinks and replacing +the JVM Properties map are not an interprocess or filesystem-lock protocol. +Separate JVMs are outside this admission guarantee. + +Successful release requires confirmed final inventory/timestamp/permission +publication, consumer preparation, user-storage retirement, persistence-worker +termination and successful return from the unload/completion hooks. It also +requires actual termination of the native executor identities captured before unload can replace +their fields. A daemon continuation observes that termination without extending +the caller's 15-second budget or blocking the server thread. Partial startup with +no admitted native writer releases after successful cleanup and retirement of +its created executors. + +An exceptional or cancelled preparation/publication stage is different from an +owner-drain acknowledgement: it can settle while older admitted native writes +still run. Existing adapters cannot prove complete drain after that outcome, so +the opaque token is conservatively retained until JVM restart, even if another +final save subsequently succeeds or the observed executors later exit. The +successor continues to fail closed. A pending successful save or preparation may +release when it genuinely completes; a timeout warning alone is not failure or +permission to discard the token. No manual token removal is a supported recovery +path while the JVM may still contain predecessor writers. Completion-stage +adapters must include all earlier admitted writes in their successful publication +boundary and may not report mere task submission as completed publication. ## What this does not port yet diff --git a/docs/shared-sql-user-access.md b/docs/shared-sql-user-access.md index fd396158eb..73ae6a805e 100644 --- a/docs/shared-sql-user-access.md +++ b/docs/shared-sql-user-access.md @@ -96,3 +96,20 @@ to surface acquisition and query failures with their original cause. A failed re cannot publish a schema-only/default snapshot, and a failed delete cannot remove identity or runtime caches. Existing `getExact`/`getExactQuery` callers retain the legacy read fallback; legacy deletion also leaves caches intact on SQL failure. + +## Spigot shutdown worker ownership + +The user-data manager retains the exact identity of its private storage executor +thread. That worker remains a storage lane during server shutdown, even when +Spigot's primary-thread probe reports every thread as primary after the server +has stopped. Shared-cache retirement still runs asynchronously on this worker. +All other threads retain the existing Bukkit and Folia tick-thread checks; +matching a worker name or disabling a plugin does not bypass them. This needs no +configuration or data migration and does not change the public retirement API. + +Shutdown uses one 15-second monotonic budget from plugin-disable entry, including +the deferred-storage watchdog and forced-worker waits. A driver that ignores an +interrupt keeps its retirement stage pending and its native provider owned until +the actual worker exits. Owned workers are daemon threads; deadline expiry never +reports an unfinished flush as successful. See the shared runtime foundation +contract for final-file persistence and synchronous consumer-hook limitations.