diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index f9cb94287b..a1b1badba5 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -11,7 +11,7 @@ on: push: branches: [ "master" ] pull_request: - branches: [ "master" ] + branches: [ "master", "codex/shared-sql-backend-init" ] permissions: contents: write # required for dependency submission diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 3b82d9b019..d665c354ec 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -11,7 +11,9 @@ import java.util.LinkedList; import java.util.Map.Entry; import java.util.Queue; -import java.util.UUID; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -43,10 +45,13 @@ import com.bencodez.advancedcore.api.time.TimeChecker; import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; -import com.bencodez.advancedcore.api.user.UserDataFetchMode; -import com.bencodez.advancedcore.api.user.UserManager; -import com.bencodez.advancedcore.api.user.UserStartup; -import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStartup; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.advancedcore.command.CommandLoader; @@ -185,7 +190,30 @@ public static void setInstance(AdvancedCorePlugin plugin) { @Getter private CMIHandler cmiHandle; - private Database database; + private Database database; + /** + * A coherent native-owner snapshot for callers which need both the selected + * storage kind and its provider. Shared-runtime replacement publishes this + * as one volatile value; callers must not combine a separately observed + * storage type with the mutable provider fields. + */ + public record UserStorageOwner(UserStorage storageType, MySQL mysql, UserTable table) { + public UserStorageOwner { + if (storageType == null) throw new IllegalArgumentException("storageType"); + if (storageType == UserStorage.MYSQL && mysql == null) throw new IllegalArgumentException("mysql"); + if (storageType == UserStorage.SQLITE && table == null) throw new IllegalArgumentException("table"); + } + } + private volatile UserStorageOwner nativeUserStorageOwner; + /** Coalesces public storage reload requests while a replacement is being prepared. */ + private Object userStorageReloadLock = new Object(); + private CompletionStage userStorageReload; + /** + * Storage-backed tab completion cannot enumerate the user provider on the + * Bukkit thread during a shared-storage replacement. Keep at most one + * enumeration in flight and apply only the newest completed snapshot. + */ + private final UuidTabCompletionRefresh uuidTabCompletionRefresh = new UuidTabCompletionRefresh(); /** * Handler for full inventory management. @@ -508,10 +536,35 @@ public void run() { * @param from the source storage type * @param to the target storage type */ - public void convertDataStorage(UserStorage from, UserStorage to) { - debug("Starting convert process"); - if (to == null) { - throw new RuntimeException("Invalid Storage Method"); + public void convertDataStorage(UserStorage from, UserStorage to) { + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("User storage conversion must run asynchronously; use convertDataStorageAsync"); + } + getUserManager().getDataManager().runStorageMaintenance(() -> convertDataStorageNow(from, to)); + } + + /** + * Start an explicit SQL-to-SQL conversion without blocking the server thread. + * The result completes after the shared cache generation was flushed and the + * converter has finished; callers must not report success before then. + */ + public CompletionStage convertDataStorageAsync(UserStorage from, UserStorage to) { + CompletableFuture result = new CompletableFuture<>(); + try { + getBukkitScheduler().runTaskAsynchronously(this, () -> { + try { + convertDataStorage(from, to); + result.complete(null); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); + } catch (RuntimeException | Error failure) { result.completeExceptionally(failure); } + return result; + } + + private void convertDataStorageNow(UserStorage from, UserStorage to) { + debug("Starting convert process"); + if (to == null) { + throw new RuntimeException("Invalid Storage Method"); } loadUserAPI(from); loadUserAPI(to); @@ -602,7 +655,7 @@ public void extraDebug(String debug) { * * @return the user table, or null if not using SQLite */ - public UserTable getSQLiteUserTable() { + public UserTable getSQLiteUserTable() { if (database == null && loadUserData) { loadUserAPI(getStorageType()); } @@ -613,29 +666,53 @@ public UserTable getSQLiteUserTable() { } } } - return null; - } + return null; + } + + /** + * Returns the current native provider as one immutable observation. This is + * intentionally separate from the legacy individual getters: user-facing + * bulk APIs use this snapshot while a shared route is being replaced. + */ + public UserStorageOwner getNativeUserStorageOwner() { + UserStorageOwner owner = nativeUserStorageOwner; + if (owner != null) return owner; + UserStorage configured = getOptions().getStorageType(); + if (configured == UserStorage.MYSQL && mysql != null) return new UserStorageOwner(configured, mysql, null); + if (configured == UserStorage.SQLITE && database != null) { + for (Table table : database.getTables()) { + if (table instanceof UserTable userTable) return new UserStorageOwner(configured, null, userTable); + } + } + return null; + } /** * Gets the current storage type configuration. * * @return the storage type */ - public UserStorage getStorageType() { - return getOptions().getStorageType(); - } + public UserStorage getStorageType() { + UserStorage configured = getOptions().getStorageType(); + UserManager loadedUsers = getLoadedUserManager(); + return loadedUsers == null ? configured + : loadedUsers.getDataManager().effectiveStorageType(configured); + } /** * Gets the user manager instance. * * @return the user manager */ - public UserManager getUserManager() { + public UserManager getUserManager() { if (userManager == null) { userManager = new UserManager(this); } - return userManager; - } + return userManager; + } + + /** Existing manager only; shutdown must not allocate a new user subsystem. */ + public UserManager getLoadedUserManager() { return userManager; } private YamlConfiguration getVersionFile() { try { @@ -690,12 +767,19 @@ public void loadAdvancedCoreEvents() { Bukkit.getPluginManager().registerEvents(new BInventoryListener(this), this); } - private void loadConfig(boolean userStorage) { - getOptions().load(this); - if (loadUserData && userStorage) { - loadUserAPI(getOptions().getStorageType()); - } - } + private void loadConfig(boolean userStorage) { + getOptions().load(this); + if (loadUserData && userStorage) { + loadUserAPI(getOptions().getStorageType()); + bindSharedUserRuntime(); + } + } + + /** Bind only after the native Bukkit storage owner has initialized successfully. */ + private void bindSharedUserRuntime() { + UserDataManager manager = getUserManager().getDataManager(); + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(this, manager); + } private void loadHandle() { @@ -921,26 +1005,7 @@ public void updateReplacements() { @Override public void reload() { - LinkedHashSet uuids = new LinkedHashSet<>(); - - // UUIDs from storage - for (String uuid : getUserManager().getAllUUIDs()) { - if (uuid != null && !uuid.isEmpty()) { - uuids.add(uuid); - } - } - - // Also include online players UUIDs depending on mode - for (Player player : Bukkit.getOnlinePlayers()) { - String uuid = getOptions().isOnlineMode() ? player.getUniqueId().toString() - : UuidLookup.getInstance().getUUID(player.getName()); // name-derived in offline-mode - - if (uuid != null && !uuid.isEmpty()) { - uuids.add(uuid); - } - } - - setReplace(new ArrayList<>(uuids)); + uuidTabCompletionRefresh.request(AdvancedCorePlugin.this, this); } @Override @@ -1023,17 +1088,19 @@ public void updateReplacements() { * * @param storageType the storage type to load */ - public void loadUserAPI(UserStorage storageType) { - if (storageType == null) { - throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); - } - if (storageType.equals(UserStorage.SQLITE)) { + public void loadUserAPI(UserStorage storageType) { + if (storageType == null) { + throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); + } + requireUserStorageMaintenanceWindow(); + if (storageType.equals(UserStorage.SQLITE)) { ArrayList columns = new ArrayList<>(); Column key = new Column("uuid", DataType.STRING); columns.add(key); UserTable table = new UserTable(this, "Users", columns, key); - database = new Database(this, "Users", table); - table.addCustomColumns(); + database = new Database(this, "Users", table); + table.addCustomColumns(); + nativeUserStorageOwner = new UserStorageOwner(UserStorage.SQLITE, null, table); } else if (storageType.equals(UserStorage.MYSQL)) { if (getOptions().getYmlConfig().getData().contains("Database")) { setMysql(new MySQL(javaPlugin, javaPlugin.getName() + "_Users", @@ -1046,7 +1113,22 @@ public void loadUserAPI(UserStorage storageType) { } } - } + } + + /** + * The shared runtime owns cache flushing and the lifecycle admission for the + * native SQL provider. Replacing that provider in-place would let an in-flight + * flush target a connection that reload has already replaced or closed. There + * is no safe synchronous Bukkit hot-reload boundary for this today, so require + * a full plugin restart before mutating either native storage owner. + */ + private void requireUserStorageMaintenanceWindow() { + UserManager loadedUsers = getLoadedUserManager(); + if (loadedUsers != null && loadedUsers.getDataManager().hasSharedRuntimeLifecycle() + && !loadedUsers.getDataManager().isStorageMaintenanceActive()) { + throw new IllegalStateException("User storage reload requires a full plugin restart while shared user storage is active or retiring"); + } + } private void loadUUIDs() { @@ -1090,6 +1172,107 @@ public void onStartUp(AdvancedCoreUser user) { TabCompleteHandler.getInstance().reload(); TabCompleteHandler.getInstance().loadTabCompleteOptions(); TabCompleteHandler.getInstance().loadTimer(getTimer()); + } + + /** + * Coalesces the storage-backed UUID replacement refresh so a reload never + * enumerates storage from the Bukkit thread. Bukkit player access and the + * replacement mutation remain on the global scheduler. A newer request wins + * over an older worker result. + */ + static final class UuidTabCompletionRefresh { + private final Object lock = new Object(); + private long nextGeneration; + private RefreshRequest current; + private boolean refreshInProgress; + + void request(AdvancedCorePlugin plugin, TabCompleteHandle handle) { + RefreshRequest refresh; + boolean schedule = false; + synchronized (lock) { + refresh = new RefreshRequest(++nextGeneration, handle); + current = refresh; + if (!refreshInProgress) { + refreshInProgress = true; + schedule = true; + } + } + if (schedule) schedule(plugin, refresh); + } + + private void schedule(AdvancedCorePlugin plugin, RefreshRequest refresh) { + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> enumerateStorage(plugin, refresh)); + } catch (Throwable failure) { + finish(plugin, refresh); + plugin.debug(failure); + } + } + + private void enumerateStorage(AdvancedCorePlugin plugin, RefreshRequest refresh) { + ArrayList storageUuids = new ArrayList<>(); + Throwable failure = null; + try { + for (String uuid : plugin.getUserManager().getAllUUIDs()) { + if (uuid != null && !uuid.isEmpty()) storageUuids.add(uuid); + } + } catch (Throwable caught) { + failure = caught; + } + final Throwable refreshFailure = failure; + try { + plugin.getBukkitScheduler().runTask(plugin, + () -> applyOnGlobalScheduler(plugin, refresh, storageUuids, refreshFailure)); + } catch (Throwable schedulingFailure) { + finish(plugin, refresh); + plugin.debug(schedulingFailure); + } + } + + private void applyOnGlobalScheduler(AdvancedCorePlugin plugin, RefreshRequest refresh, + ArrayList storageUuids, Throwable failure) { + try { + if (!isCurrent(refresh)) return; + if (failure != null) { + plugin.debug(failure); + return; + } + + LinkedHashSet uuids = new LinkedHashSet<>(storageUuids); + for (Player player : Bukkit.getOnlinePlayers()) { + String uuid = plugin.getOptions().isOnlineMode() ? player.getUniqueId().toString() + : UuidLookup.getInstance().getUUID(player.getName()); + if (uuid != null && !uuid.isEmpty()) uuids.add(uuid); + } + + ArrayList replacements = new ArrayList<>(uuids); + refresh.handle().setReplace(replacements); + TabCompleteHandler.getInstance().getTabCompleteOptions().put(refresh.handle().getToReplace(), replacements); + } finally { + finish(plugin, refresh); + } + } + + private boolean isCurrent(RefreshRequest refresh) { + synchronized (lock) { + return refresh.equals(current); + } + } + + private void finish(AdvancedCorePlugin plugin, RefreshRequest completed) { + RefreshRequest next = null; + synchronized (lock) { + if (!completed.equals(current)) { + next = current; + } else { + refreshInProgress = false; + } + } + if (next != null) schedule(plugin, next); + } + + private record RefreshRequest(long generation, TabCompleteHandle handle) { + } } /** @@ -1201,35 +1384,212 @@ public void reloadAdvancedCore() { reloadAdvancedCore(false); } - /** - * Reloads AdvancedCore configuration. - * - * @param userStorage whether to reload user storage - */ - public void reloadAdvancedCore(boolean userStorage) { - getServerDataFile().reloadData(); - rewardHandler.loadRewards(); - loadConfig(userStorage); - - if (userStorage) { - getUserManager().getDataManager().clearCache(); - if (getStorageType().equals(UserStorage.MYSQL) && getMysql() != null) { - getMysql().clearCacheBasic(); - } - } - timeChecker.update(); - TabCompleteHandler.getInstance().reload(); - TabCompleteHandler.getInstance().loadTabCompleteOptions(); - getRewardHandler().checkSubRewards(); - - if (skullCacheHandler != null) { - if (!getOptions().getSkullProfileAPIURL().isEmpty()) { - debug("Setting API profile URL to " + getOptions().getSkullProfileAPIURL()); - skullCacheHandler.changeApiProfileURL(getOptions().getSkullProfileAPIURL()); - } - getSkullCacheHandler().setBedrockPrefix(getOptions().getBedrockPlayerPrefix()); - } - } + /** + * Starts an AdvancedCore configuration reload. + * + *

When {@code userStorage} is true and a shared runtime is active, this + * method only starts the non-blocking reload; it returns before storage has + * been flushed, replaced, or confirmed. It deliberately has no success + * signal. Call {@link #reloadAdvancedCoreAsync(boolean)} when a caller must + * continue, notify an administrator, or report status after completion. + * + * @param userStorage whether to reload user storage + */ + public void reloadAdvancedCore(boolean userStorage) { + if (userStorage && hasActiveSharedUserRuntime()) { + reloadAdvancedCoreAsync(true).whenComplete((ignored, failure) -> { + if (failure != null) { + getLogger().warning("User storage reload did not complete: " + failure.getMessage()); + debug(failure); + } + }); + return; + } + reloadAdvancedCoreNow(userStorage); + } + + /** + * Reload AdvancedCore without making a Bukkit thread wait for a shared user + * storage flush or a database connection. Callers that need to report a + * confirmed storage reload should await this stage rather than assuming that + * the legacy void overload has completed synchronously. + * + * @param userStorage whether to reload user storage + * @return completion of the reload, including the shared backend replacement + */ + public CompletionStage reloadAdvancedCoreAsync(boolean userStorage) { + if (!userStorage || !hasActiveSharedUserRuntime()) { + CompletableFuture completion = new CompletableFuture<>(); + try { + reloadAdvancedCoreNow(userStorage); + completion.complete(null); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + return completion; + } + synchronized (userStorageReloadLock()) { + if (userStorageReload != null && !userStorageReload.toCompletableFuture().isDone()) return userStorageReload; + CompletableFuture completion = new CompletableFuture<>(); + userStorageReload = completion; + try { + getBukkitScheduler().runTask(this, () -> beginSharedUserStorageReload(completion)); + } catch (RuntimeException | Error failure) { + completion.completeExceptionally(failure); + userStorageReload = null; + } + return completion; + } + } + + private boolean hasActiveSharedUserRuntime() { + UserManager users = getLoadedUserManager(); + return users != null && users.getDataManager().hasSharedRuntime(); + } + + private void beginSharedUserStorageReload(CompletableFuture completion) { + try { + getServerDataFile().reloadData(); + rewardHandler.loadRewards(); + getOptions().load(this); + getBukkitScheduler().runTaskAsynchronously(this, () -> replaceSharedUserStorage(completion)); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + clearSharedUserStorageReload(completion); + } + } + + private void replaceSharedUserStorage(CompletableFuture completion) { + UserStorageReplacement replacement = null; + try { + replacement = prepareUserStorageReplacement(getOptions().getStorageType()); + UserStorageReplacement prepared = replacement; + getUserManager().getDataManager().replaceSharedSqlBackendAsync(prepared.backend(), + () -> installUserStorageReplacement(prepared)).whenComplete((ignored, failure) -> { + if (failure != null) { + // SharedUserDataRuntime reports an exception here only before it + // publishes the replacement route. A post-publication old-owner close + // is retained there for retry and deliberately completes successfully. + prepared.closeUnpublished(); + completion.completeExceptionally(failure); + clearSharedUserStorageReload(completion); + return; + } + try { + getBukkitScheduler().runTask(this, () -> completeSharedUserStorageReload(completion)); + } catch (Throwable completionFailure) { + completion.completeExceptionally(completionFailure); + clearSharedUserStorageReload(completion); + } + }); + } catch (Throwable failure) { + if (replacement != null) replacement.closeUnpublished(); + completion.completeExceptionally(failure); + clearSharedUserStorageReload(completion); + } + } + + private void completeSharedUserStorageReload(CompletableFuture completion) { + try { + finishReloadAdvancedCore(); + completion.complete(null); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + finally { clearSharedUserStorageReload(completion); } + } + + private void clearSharedUserStorageReload(CompletableFuture completion) { + synchronized (userStorageReloadLock()) { + if (userStorageReload == completion) userStorageReload = null; + } + } + + private Object userStorageReloadLock() { + Object lock = userStorageReloadLock; + if (lock != null) return lock; + synchronized (this) { + if (userStorageReloadLock == null) userStorageReloadLock = new Object(); + return userStorageReloadLock; + } + } + + private UserStorageReplacement prepareUserStorageReplacement(UserStorage storageType) { + if (storageType == null) throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); + if (storageType == UserStorage.SQLITE) { + ArrayList columns = new ArrayList<>(); + Column key = new Column("uuid", DataType.STRING); + columns.add(key); + UserTable table = new UserTable(this, "Users", columns, key); + Database replacementDatabase = new Database(this, "Users", table); + table.addCustomColumns(); + return new UserStorageReplacement(storageType, replacementDatabase, null, + new BukkitSqlUserBackend(this, storageType, null, table)); + } + ConfigurationSection section = getOptions().getYmlConfig().getData() + .getConfigurationSection(getOptions().getYmlConfig().getData().contains("Database") ? "Database" : "MySQL"); + if (section == null) throw new IllegalStateException("MySQL user storage configuration is missing"); + MySQL replacementMysql = new MySQL(javaPlugin, javaPlugin.getName() + "_Users", section); + return new UserStorageReplacement(storageType, null, replacementMysql, + new BukkitSqlUserBackend(this, storageType, replacementMysql, null)); + } + + private void installUserStorageReplacement(UserStorageReplacement replacement) { + MySQL previousMysql = mysql; + Database previousDatabase = database; + mysql = replacement.mysql(); + database = replacement.database(); + // Publish only after both legacy fields have been assigned. The shared route + // picks this exact snapshot up in the same lifecycle write admission. + nativeUserStorageOwner = replacement.owner(); + if (previousMysql != null && previousMysql != mysql) { + try { previousMysql.close(); } + catch (RuntimeException | Error closeFailure) { debug(closeFailure); } + } + if (previousDatabase != null && previousDatabase != database) { + try { previousDatabase.getDB().closeConnection(); } + catch (RuntimeException | Error closeFailure) { debug(closeFailure); } + } + } + + private void reloadAdvancedCoreNow(boolean userStorage) { + if (userStorage) requireUserStorageMaintenanceWindow(); + getServerDataFile().reloadData(); + rewardHandler.loadRewards(); + loadConfig(userStorage); + if (userStorage) { + getUserManager().getDataManager().clearCache(); + if (getStorageType().equals(UserStorage.MYSQL) && getMysql() != null) getMysql().clearCacheBasic(); + } + finishReloadAdvancedCore(); + } + + private void finishReloadAdvancedCore() { + timeChecker.update(); + TabCompleteHandler.getInstance().reload(); + TabCompleteHandler.getInstance().loadTabCompleteOptions(); + getRewardHandler().checkSubRewards(); + if (skullCacheHandler != null) { + if (!getOptions().getSkullProfileAPIURL().isEmpty()) { + debug("Setting API profile URL to " + getOptions().getSkullProfileAPIURL()); + skullCacheHandler.changeApiProfileURL(getOptions().getSkullProfileAPIURL()); + } + getSkullCacheHandler().setBedrockPrefix(getOptions().getBedrockPlayerPrefix()); + } + } + + private record UserStorageReplacement(UserStorage storageType, Database database, MySQL mysql, + BukkitSqlUserBackend backend) { + private UserStorageOwner owner() { + return new UserStorageOwner(storageType, mysql, database == null ? null : findUserTable(database)); + } + + private static UserTable findUserTable(Database database) { + for (Table table : database.getTables()) if (table instanceof UserTable userTable) return userTable; + throw new IllegalStateException("Replacement SQLite user table is unavailable"); + } + private void closeUnpublished() { + backend.close(); + if (mysql != null) mysql.close(); + if (database != null) database.getDB().closeConnection(); + } + } /** * @param configData the configData to set @@ -1269,13 +1629,14 @@ public void setConfigData(YMLConfig ymlConfig) { * * @param mysql the mysql connection to set */ - public void setMysql(MySQL mysql) { + public void setMysql(MySQL mysql) { if (this.mysql != null) { this.mysql.close(); this.mysql = null; - } - this.mysql = mysql; - } + } + this.mysql = mysql; + if (mysql != null) nativeUserStorageOwner = new UserStorageOwner(UserStorage.MYSQL, mysql, null); + } /** * Runs user startup tasks. diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java index 9c850fea92..35b9e4a1d4 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java @@ -179,11 +179,10 @@ public String getPlayerName(AdvancedCoreUser user, String uuid, boolean useCache cacheMapping(uuid, liveName); // Update stored PlayerName if it changed / missing - if (user != null && user.getUserData().hasData()) { - if (storedName.isEmpty() || storedName.equalsIgnoreCase("Error getting name") - || !liveName.equals(storedName)) { - user.getData().setString("PlayerName", liveName); - } + if (user != null && (storedName.isEmpty() || storedName.equalsIgnoreCase("Error getting name") + || !liveName.equals(storedName))) { + user.setPlayerName(liveName); + user.updateName(false); } return liveName; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index 5ec245f330..d24674b895 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java @@ -506,6 +506,7 @@ public static ReplayState replayStateFor(RewardOptions options) { if (options.getAsyncReplayCheckpointConsumer() != null) { replayState.setCheckpointConsumer(options.getAsyncReplayCheckpointConsumer()); } + replayState.captureLivePlayerState(options); return replayState; } @@ -1005,7 +1006,10 @@ public static int completedLegacyActions(ReplayState replayState, HashMap replayMetadata = new HashMap<>(); private final boolean legacyCheckpoint; private final boolean restoredCheckpoint; + private boolean livePlayerStateSet; + private boolean livePlayerOnline; + private boolean livePlayerVanished; private Consumer checkpointConsumer; private ReplayState(Map initial) { this(initial, null, false); } private ReplayState(Map initial, Map initialFingerprints, @@ -1122,6 +1132,15 @@ private synchronized int highestCompletedCount() { private synchronized void setCheckpointConsumer(Consumer consumer) { checkpointConsumer = consumer; } + private synchronized void captureLivePlayerState(RewardOptions options) { + if (!options.isLivePlayerStateSet()) return; + livePlayerStateSet = true; + livePlayerOnline = options.isOnline(); + livePlayerVanished = options.isLivePlayerVanished(); + } + private synchronized void applyLivePlayerState(RewardOptions options) { + if (livePlayerStateSet) options.captureLivePlayerState(livePlayerOnline, livePlayerVanished); + } private synchronized boolean hasCheckpointConsumer() { return checkpointConsumer != null; } @@ -1560,7 +1579,17 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R || checkTimed(user, rewardOptions.getPlaceholders()))) { return CompletableFuture.completedFuture(null); } - if (!rewardOptions.isOnlineSet()) rewardOptions.setOnline(user.isOnline()); + RewardOptions stableOptions = rewardOptions; + return requestOnServerThread(user, () -> evaluateLiveRewardDecision(user, stableOptions)) + .thenCompose(decision -> finishRewardAsync(user, stableOptions, decision)); + } + + private CompletionStage evaluateLiveRewardDecision(AdvancedCoreUser user, + RewardOptions rewardOptions) { + boolean liveOnline = rewardOptions.isLivePlayerStateSet() ? rewardOptions.isOnline() : user.isOnline(); + boolean vanished = plugin.getOptions().isTreatVanishAsOffline() + && (rewardOptions.isLivePlayerStateSet() ? rewardOptions.isLivePlayerVanished() : user.isVanished()); + if (!rewardOptions.isLivePlayerStateSet()) rewardOptions.captureLivePlayerState(liveOnline, vanished); for (RewardPlaceholderHandle handle : plugin.getRewardHandler().getPlaceholders()) { if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user)); } @@ -1572,7 +1601,10 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R try { if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { canGive = false; - if (!inject.isAllowReattempt()) return CompletableFuture.completedFuture(null); + if (!inject.isAllowReattempt()) { + return CompletableFuture.completedFuture( + new LiveRewardDecision(false, false, vanished, false, true)); + } allowOffline = true; } } catch (Exception e) { @@ -1586,21 +1618,27 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R } } } - if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { - checkRewardFile(); - preserveReplayState(rewardOptions); - return deferRewardAsync(user, rewardOptions); + boolean verifyOnline = !rewardOptions.isOnline() || rewardOptions.getServer() != null; + boolean liveOffline = verifyOnline && !liveOnline; + return CompletableFuture.completedFuture( + new LiveRewardDecision(canGive, allowOffline, vanished, liveOffline, false)); + } + + private CompletionStage finishRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions, + LiveRewardDecision decision) { + if (decision.terminalDenied()) return CompletableFuture.completedFuture(null); + boolean vanished = decision.vanished(); + if (plugin.getOptions().isPauseRewards() || vanished) { + return deferRewardWithoutBlockingOwner(user, rewardOptions); } - if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline) - && (!isForceOffline() && !rewardOptions.isForceOffline()))) { + if ((decision.liveOffline() || decision.allowOffline()) && !isForceOffline() + && !rewardOptions.isForceOffline()) { if (rewardOptions.isGiveOffline()) { - checkRewardFile(); - preserveReplayState(rewardOptions); - return deferRewardAsync(user, rewardOptions); + return deferRewardWithoutBlockingOwner(user, rewardOptions); } return CompletableFuture.completedFuture(null); } - if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { + if (decision.canGive() || isForceOffline() || rewardOptions.isForceOffline()) { plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + user.getUUID()); return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); @@ -1608,6 +1646,50 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R return CompletableFuture.completedFuture(null); } + private CompletionStage deferRewardWithoutBlockingOwner(AdvancedCoreUser user, + RewardOptions rewardOptions) { + if (rewardOptions.isTimedQueueReplay() + || (isDurableReplay(rewardOptions) && rewardOptions.getAsyncReplayCheckpointConsumer() == null)) { + return deferRewardAsync(user, rewardOptions); + } + return continueOffServerThread(() -> { + checkRewardFile(); + preserveReplayState(rewardOptions); + return deferRewardAsync(user, rewardOptions); + }); + } + + private record LiveRewardDecision(boolean canGive, boolean allowOffline, boolean vanished, boolean liveOffline, + boolean terminalDenied) { } + + private CompletionStage continueOffServerThread(Supplier> request) { + CompletableFuture> handoff = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + CompletableFuture result = new CompletableFuture<>(); + if (!handoff.complete(result)) return; + try { + CompletionStage stage = request.get(); + if (stage == null) { + result.completeExceptionally( + new IllegalStateException("Reward continuation returned no completion stage")); + return; + } + stage.whenComplete((value, failure) -> { + if (failure == null) result.complete(value); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); + } + private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { if (rewardOptions.isTimedQueueReplay()) { return CompletableFuture.failedFuture( @@ -1649,8 +1731,8 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R /** * Asynchronously gives a reward to a user when an injection opts into the - * asynchronous API. Preparation remains on the calling thread; only the - * opted-in injection chain is asynchronous. + * asynchronous API. Player-dependent preparation is handed to the player's + * owning scheduler before the opted-in injection chain continues. * * @param user receiving user * @param phs placeholders @@ -1659,13 +1741,19 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R */ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { + // This public API may hand preparation to the player's scheduler. Preserve the + // caller's values before that boundary so a reused mutable map cannot alter a + // queued reward or its durable replay checkpoint. + HashMap placeholders = phs == null ? new HashMap<>() : new HashMap<>(phs); ReplayState replayState = replayStateFor(rewardOptions); + List orderedRewards = orderedInjectedRewards(); + String registryFingerprint = injectionRegistryFingerprint(orderedRewards); String replayKey = rewardOptions.getAsyncReplayKey(); if (replayKey == null) { String parentKey = ACTIVE_REPLAY_KEY.get(); replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); } - if (!replayState.matchesRegistryFingerprint(injectionRegistryFingerprint(orderedInjectedRewards()))) { + if (!replayState.matchesRegistryFingerprint(registryFingerprint)) { return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); } String occurrenceId = rewardOptions.getAsyncReplayOccurrenceId(); @@ -1678,6 +1766,16 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< occurrenceId = UUID.randomUUID().toString(); } } + final String stableReplayKey = replayKey; + final String stableOccurrenceId = occurrenceId; + return requestOnServerThread(user, + () -> prepareAndGiveRewardUserAsync(user, placeholders, rewardOptions, replayState, stableReplayKey, + stableOccurrenceId, registryFingerprint, !orderedRewards.isEmpty())); + } + + private CompletionStage prepareAndGiveRewardUserAsync(AdvancedCoreUser user, HashMap phs, + RewardOptions rewardOptions, ReplayState replayState, String replayKey, String occurrenceId, + String registryFingerprint, boolean hasInjections) { final HashMap placeholders; try { placeholders = prepareRewardUser(user, phs); @@ -1685,14 +1783,15 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< return CompletableFuture.failedFuture(throwable); } if (placeholders == null) { - if (isDurableReplay(rewardOptions) || hasPersistedReplayCheckpoint(rewardOptions)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Player became unavailable before persisted reward replay")); - } - return CompletableFuture.completedFuture(null); - } - return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey, - occurrenceId) + IllegalStateException unavailable = + new IllegalStateException("Player became unavailable before asynchronous reward delivery"); + if (!hasInjections) return CompletableFuture.failedFuture(unavailable); + replayState.setRegistryFingerprint(replayKey, registryFingerprint); + return CompletableFuture.failedFuture(new RewardReplayFailure(replayState, + phs == null ? new HashMap<>() : phs, unavailable)); + } + return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, + replayKey, occurrenceId) .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index bcda82489a..4f248668d0 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -27,9 +27,16 @@ public class RewardOptions { private boolean online = true; - @Getter - @Setter - private boolean onlineSet = false; + @Getter + @Setter + private boolean onlineSet = false; + + /** Server-thread player state captured before a replay moves to storage work. */ + @Getter + private boolean livePlayerStateSet; + + @Getter + private boolean livePlayerVanished; private HashMap placeholders = new HashMap<>(); @@ -175,11 +182,18 @@ public RewardOptions setIgnoreRequirements(boolean ignoreRequirements) { return this; } - public RewardOptions setOnline(boolean online) { - this.online = online; - this.onlineSet = true; - return this; - } + public RewardOptions setOnline(boolean online) { + this.online = online; + this.onlineSet = true; + return this; + } + + public RewardOptions captureLivePlayerState(boolean online, boolean vanished) { + setOnline(online); + livePlayerStateSet = true; + livePlayerVanished = vanished; + return this; + } public RewardOptions setPlaceholders(HashMap placeholders) { this.placeholders = placeholders; @@ -245,6 +259,7 @@ RewardOptions copyForNestedDispatch(String replayKey) { if (forceOffline) copy.forceOffline(); if (!useDefaultWorlds) copy.disableDefaultWorlds(); if (onlineSet) copy.setOnline(online); + if (livePlayerStateSet) copy.captureLivePlayerState(online, livePlayerVanished); if (!server.isEmpty()) copy.setServer(server); copy.setAsyncReplayState(asyncReplayState); copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints)); 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 b6a3e2a352..bbb27ceed1 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 @@ -51,6 +51,7 @@ import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.simpleapi.messages.actionbar.ActionBar; import com.bencodez.simpleapi.player.PlayerUtils; @@ -528,7 +529,7 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid) { this.plugin = plugin; this.uuid = uuid.toString(); loadData(); - setPlayerName(PlayerManager.getInstance().getPlayerName(this, this.uuid, false)); + loadPlayerNameWithoutBlocking(false); } /** @@ -545,7 +546,7 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid, boolean loadName) this.loadName = loadName; loadData(); if (this.loadName) { - setPlayerName(PlayerManager.getInstance().getPlayerName(this, this.uuid)); + loadPlayerNameWithoutBlocking(true); } } @@ -567,11 +568,25 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid, boolean loadName, loadData(); } if (this.loadName) { - setPlayerName(PlayerManager.getInstance().getPlayerName(this, this.uuid)); + loadPlayerNameWithoutBlocking(true); } } + /** Resolve a UUID-backed name without performing shared SQL on Bukkit's primary thread. */ + private void loadPlayerNameWithoutBlocking(boolean useCache) { + String cached = com.bencodez.advancedcore.api.player.UuidLookup.getInstance().getCachedName(uuid); + if (!cached.isEmpty()) { + setPlayerName(cached); + return; + } + UserDataManager manager = plugin.getUserManager().getDataManager(); + if (manager.deferSharedStorageResult( + () -> PlayerManager.getInstance().getPlayerName(this, uuid, useCache), + this::setPlayerName, plugin::debug)) return; + setPlayerName(PlayerManager.getInstance().getPlayerName(this, uuid, useCache)); + } + /** * Instantiates a new user. * @@ -1033,6 +1048,15 @@ public void cacheIfNeeded() { * Checks and processes delayed/timed rewards. */ public void checkDelayedTimedRewards() { + checkDelayedTimedRewards(null); + } + + private void checkDelayedTimedRewards(ReplayPlayerState capturedState) { + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + if (manager.deferSharedStorageWork(() -> checkDelayedTimedRewards(state))) return; + } plugin.debug("Checking timed/delayed for " + getPlayerName()); HashMap timed = getTimedRewards(); for (Entry entry : timed.entrySet()) { @@ -1057,6 +1081,8 @@ public void checkDelayedTimedRewards() { } RewardOptions replayOptions = new RewardOptions().setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholders)); + if (capturedState != null) replayOptions.captureLivePlayerState( + capturedState.online(), capturedState.vanished()); replayOptions.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); replayOptions.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); replayOptions.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); @@ -1101,17 +1127,26 @@ public void checkDelayedTimedRewards() { * Check offline rewards. */ public void checkOfflineRewards() { + checkOfflineRewards(null); + } + + private void checkOfflineRewards(ReplayPlayerState capturedState) { if (!plugin.getOptions().isProcessRewards()) { 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; + } if (isCheckWorld()) { setCheckWorld(false); } - dispatchOfflineRewards(false); + dispatchOfflineRewards(false, capturedState); } - private void dispatchOfflineRewards(boolean force) { + private void dispatchOfflineRewards(boolean force, ReplayPlayerState capturedState) { ArrayList rewards = new ArrayList<>(getOfflineRewards()); for (String rewardEntry : rewards) { if (rewardEntry == null || rewardEntry.equals("null")) { @@ -1132,6 +1167,8 @@ private void dispatchOfflineRewards(boolean force) { RewardOptions options = new RewardOptions().setOnline(false).setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholderStr)); + if (capturedState != null) options.captureLivePlayerState( + capturedState.online(), capturedState.vanished()); if (force) options.setGiveOffline(false).forceOffline(); options.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); options.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); @@ -1438,15 +1475,36 @@ public AdvancedCoreUser userDataFetechMode(UserDataFetchMode mode) { * Forces running of offline rewards without processing checks. */ public void forceRunOfflineRewards() { + forceRunOfflineRewards(null); + } + + private void forceRunOfflineRewards(ReplayPlayerState capturedState) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); return; } + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + if (manager.deferSharedStorageWork(() -> forceRunOfflineRewards(state))) return; + } setCheckWorld(false); - dispatchOfflineRewards(true); + dispatchOfflineRewards(true, capturedState); + } + + private ReplayPlayerState captureReplayPlayerState() { + boolean vanished = plugin.getOptions().isTreatVanishAsOffline() && isVanished(); + return new ReplayPlayerState(isOnline(), vanished); } + private UserDataManager sharedReplayDataManager() { + UserManager users = plugin.getUserManager(); + return users == null ? null : users.getDataManager(); + } + + private record ReplayPlayerState(boolean online, boolean vanished) { } + /** * Gets the user data cache. * @@ -3022,11 +3080,20 @@ public AdvancedCoreUser tempCache() { * @param force whether to force the update */ public void updateName(boolean force) { - if (getData().hasData() || force) { - String playerName = getData().getString("PlayerName", userDataFetchMode); - if (playerName == null || !playerName.equals(getPlayerName())) { - getData().setString("PlayerName", getPlayerName(), true); - } + UserData currentData = getData(); + if (!force && plugin != null && plugin.getUserManager() != null + && plugin.getUserManager().getDataManager().deferSharedStorageResult(currentData::hasData, + hasData -> updateName(currentData, false, hasData), ignored -> {})) return; + updateName(currentData, force, force || currentData.hasData()); + } + + private void updateName(UserData currentData, boolean force, boolean hasData) { + if (!hasData && !force) return; + String resolvedName = getPlayerName(); + if (resolvedName == null || resolvedName.isBlank()) return; + String storedName = currentData.getString("PlayerName", userDataFetchMode); + if (storedName == null || !storedName.equals(resolvedName)) { + currentData.setString("PlayerName", resolvedName, true); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserData.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserData.java index 855da04a77..fd0828f099 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserData.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserData.java @@ -4,8 +4,11 @@ import java.util.HashMap; import java.util.List; -import com.bencodez.advancedcore.api.user.usercache.UserDataCache; -import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeBoolean; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString; import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage; import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess; @@ -83,12 +86,12 @@ public int getInt(String key, UserDataFetchMode mode) { return getInt(key, 0, mode); } - public int getInt(String key, int def) { - return getInt(user.getPlugin().getStorageType(), key, def, user.getUserDataFetchMode()); - } + public int getInt(String key, int def) { + return getInt(effectiveStorageType(), key, def, user.getUserDataFetchMode()); + } public int getInt(String key, int def, UserDataFetchMode mode) { - return getInt(user.getPlugin().getStorageType(), key, def, mode); + return getInt(effectiveStorageType(), key, def, mode); } /** @@ -112,7 +115,7 @@ public int getInt(String key, boolean useCache, boolean waitForCache) { */ @Deprecated public int getInt(String key, int def, boolean waitForCache) { - return getInt(user.getPlugin().getStorageType(), key, def, UserDataFetchMode.fromBooleans(true, waitForCache)); + return getInt(effectiveStorageType(), key, def, UserDataFetchMode.fromBooleans(true, waitForCache)); } /** @@ -120,14 +123,18 @@ public int getInt(String key, int def, boolean waitForCache) { */ @Deprecated public int getInt(String key, int def, boolean useCache, boolean waitForCache) { - return getInt(user.getPlugin().getStorageType(), key, def, + return getInt(effectiveStorageType(), key, def, UserDataFetchMode.fromBooleans(useCache, waitForCache)); } - public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mode) { - if (key == null || key.isEmpty()) { - return def; - } + public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mode) { + if (key == null || key.isEmpty()) { + return def; + } + // The shared cache belongs to exactly one physical store. Check this + // before consulting any cache layer so an explicit alternate-store read + // cannot be answered with a value from the active shared backend. + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); // 1) Temp cache if (mode.allowTempCache() && tempCache != null) { @@ -151,9 +158,11 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo } } - // 2) UserDataCache - if (mode.allowUserCache()) { - UserDataCache cache = user.getCache(); + // 2) UserDataCache + UserDataCache sharedReadCache = null; + if (mode.allowUserCache()) { + UserDataCache cache = user.getCache(); + sharedReadCache = cache; if (cache != null) { // preserve previous behavior user.cacheIfNeeded(); @@ -177,17 +186,21 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo user.cache(); } + if (!mode.allowStorageLookup()) { + return def; + } + } else { if (!mode.allowStorageLookup()) { return def; } - } else { - if (!mode.allowStorageLookup()) { - return def; - } - } - - // 3) Storage lookup - return sqlData.getInt(storage, key, def); + } + + // 3) Storage lookup + if (mustDeferSharedStorageAccess()) { + rejectUnavailableFreshRead(mode, sharedReadCache); + return def; + } + return sqlData.getInt(storage, key, def); } /** @@ -199,19 +212,21 @@ public int getInt(UserStorage storage, String key, int def, boolean useCache, bo } public ArrayList getKeys() { - return getKeys(user.getPlugin().getStorageType()); + return getKeys(effectiveStorageType()); } - public ArrayList getKeys(UserStorage storage) { - return sqlData.getKeys(storage); - } + public ArrayList getKeys(UserStorage storage) { + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + rejectPrimaryThreadPersistedRowRead(storage); + return sqlData.getKeys(storage); + } /** * @deprecated Use {@link #getKeys()} or {@link #getKeys(UserStorage)} */ @Deprecated public ArrayList getKeys(boolean waitForCache) { - return getKeys(user.getPlugin().getStorageType()); + return getKeys(effectiveStorageType()); } /** @@ -235,13 +250,16 @@ public String getString(String key) { } public String getString(String key, UserDataFetchMode mode) { - return getString(user.getPlugin().getStorageType(), key, mode); + return getString(effectiveStorageType(), key, mode); } - public String getString(UserStorage storage, String key, UserDataFetchMode mode) { - if (key == null || key.isEmpty()) { - return ""; - } + public String getString(UserStorage storage, String key, UserDataFetchMode mode) { + if (key == null || key.isEmpty()) { + return ""; + } + // See getInt(UserStorage,...): cache contents are only valid for the + // runtime-owned store. + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); // 1) Temp cache if (mode.allowTempCache() && tempCache != null) { @@ -258,9 +276,11 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) } } - // 2) UserDataCache - if (mode.allowUserCache()) { - UserDataCache cache = user.getCache(); + // 2) UserDataCache + UserDataCache sharedReadCache = null; + if (mode.allowUserCache()) { + UserDataCache cache = user.getCache(); + sharedReadCache = cache; if (cache != null) { if (cache.isCached(key)) { DataValue cv = cache.getCache().get(key); @@ -274,18 +294,33 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) user.cache(); } + if (!mode.allowStorageLookup()) { + return ""; + } + } else { if (!mode.allowStorageLookup()) { return ""; } - } else { - if (!mode.allowStorageLookup()) { - return ""; - } - } - - // 3) Storage lookup - return sqlData.getString(storage, key); - } + } + + // 3) Storage lookup + if (mustDeferSharedStorageAccess()) { + rejectUnavailableFreshRead(mode, sharedReadCache); + return ""; + } + return sqlData.getString(storage, key); + } + + private void rejectUnavailableFreshRead(UserDataFetchMode mode, UserDataCache cache) { + if (mode.allowUserCache() && cache != null && cache.hasPublishedStorageSnapshot()) return; + throw new IllegalStateException( + "Shared user data is still loading; defer this read until cache population completes"); + } + + private boolean mustDeferSharedStorageAccess() { + UserDataManager dataManager = sharedDataManager(); + return dataManager != null && dataManager.mustDeferSharedStorageAccess(); + } /** * @deprecated Use {@link #getString(String, UserDataFetchMode)} @@ -340,21 +375,36 @@ public String getValue(String key) { } public HashMap getValues() { - return getValues(user.getPlugin().getStorageType()); - } - - public HashMap getValues(UserStorage storage) { - return convert(sqlData.readRow(storage)); - } - - public boolean hasData() { - return sqlData.hasData(user.getPlugin().getStorageType()); - } - - public void remove() { - sqlData.remove(user.getPlugin().getStorageType()); - user.clearCache(); - } + return getValues(effectiveStorageType()); + } + + public HashMap getValues(UserStorage storage) { + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + rejectPrimaryThreadPersistedRowRead(storage); + return convert(sqlData.readRow(storage)); + } + + public boolean hasData() { + UserStorage storage = effectiveStorageType(); + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + UserDataCache sharedCache = primaryThreadSharedCache(storage); + return sharedCache == null ? sqlData.hasData(storage) : sharedCache.hasStoredData(); + } + + public void remove() { + UserStorage storage = effectiveStorageType(); + UserDataManager manager = sharedDataManager(); + if (manager != null && manager.hasSharedRuntime()) { + // The runtime serializes flush, delete, and cache retirement under its + // exclusive per-user gate. Deleting through the adapter and then + // clearing the cache separately can otherwise let queued work recreate + // the row after its deletion. + manager.removeUserData(java.util.UUID.fromString(user.getUUID()), storage); + return; + } + sqlData.remove(storage); + user.clearCache(); + } public void setBoolean(String key, boolean value) { setString(key, "" + value); @@ -369,18 +419,18 @@ public void setInt(final String key, final int value) { } public void setInt(final String key, final int value, boolean queue) { - setInt(user.getPlugin().getStorageType(), key, value, queue); + setInt(effectiveStorageType(), key, value, queue); } public void setInt(final String key, final int value, boolean queue, boolean async) { - setInt(user.getPlugin().getStorageType(), key, value, queue, async); + setInt(effectiveStorageType(), key, value, queue, async); } public void setInt(UserStorage storage, final String key, final int value, boolean queue) { setInt(storage, key, value, queue, false); } - public void setInt(final UserStorage storage, final String key, final int value, boolean queue, boolean async) { + public void setInt(final UserStorage storage, final String key, final int value, boolean queue, boolean async) { if (key.equals("")) { user.getPlugin().debug("No key: " + key + " to " + value); return; @@ -392,12 +442,24 @@ public void setInt(final UserStorage storage, final String key, final int value, user.getPlugin().extraDebug("PlayerData " + storage.toString() + ": Setting " + key + " to '" + value + "' for '" + user.getPlayerName() + "/" + user.getUUID() + "' Queue: " + queue); - if (user.isCached()) { - user.getCache().addChange(new UserDataChangeInt(key, value), queue); - user.getPlugin().getUserManager().onChange(user, key); - if (queue) { - return; - } + UserDataChangeInt change = new UserDataChangeInt(key, value); + if (queueSharedMutation(storage, change, queue, async)) return; + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + + if (user.isCached()) { + boolean flushImmediately = !queue && user.getPlugin().getUserManager().getDataManager() + .usesSharedSqlStorage(storage); + user.getCache().addChange(change, queue || flushImmediately); + // An immediate shared flush reports the change from its persistence + // completion callback. Keep the legacy eager callback for ordinary + // queued/cache-only changes, but do not report this direct write twice. + if (!flushImmediately) user.getPlugin().getUserManager().onChange(user, key); + if (queue || flushImmediately) { + if (flushImmediately) { + user.getCache().processChangesImmediately(async); + } + return; + } } if (async) { @@ -427,19 +489,19 @@ public void setString(final String key, final String value) { } public void setString(final String key, final String value, boolean queue) { - setString(user.getPlugin().getStorageType(), key, value, queue); + setString(effectiveStorageType(), key, value, queue); } public void setString(final String key, final String value, boolean queue, boolean async) { - setString(user.getPlugin().getStorageType(), key, value, queue, async); + setString(effectiveStorageType(), key, value, queue, async); } public void setString(UserStorage storage, final String key, final String value, boolean queue) { setString(storage, key, value, queue, false); } - public void setString(final UserStorage storage, final String key, final String value, boolean queue, - boolean async) { + public void setString(final UserStorage storage, final String key, final String value, boolean queue, + boolean async) { if (key.equals("") && value != null) { user.getPlugin().debug("No key/value: " + key + " to " + value); return; @@ -451,12 +513,21 @@ public void setString(final UserStorage storage, final String key, final String user.getPlugin().extraDebug("PlayerData " + storage.toString() + ": Setting " + key + " to '" + value + "' for '" + user.getPlayerName() + "/" + user.getUUID() + "' Queue: " + queue); - if (user.isCached()) { - user.getCache().addChange(new UserDataChangeString(key, value), queue); - user.getPlugin().getUserManager().onChange(user, key); - if (queue) { - return; - } + UserDataChangeString change = new UserDataChangeString(key, value); + if (queueSharedMutation(storage, change, queue, async)) return; + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + + if (user.isCached()) { + boolean flushImmediately = !queue && user.getPlugin().getUserManager().getDataManager() + .usesSharedSqlStorage(storage); + user.getCache().addChange(change, queue || flushImmediately); + if (!flushImmediately) user.getPlugin().getUserManager().onChange(user, key); + if (queue || flushImmediately) { + if (flushImmediately) { + user.getCache().processChangesImmediately(async); + } + return; + } } if (async) { @@ -477,7 +548,128 @@ public void run() { } } - } + } + + /** + * A primary-thread cache miss is represented by a placeholder whose database + * snapshot is already queued on the manager worker. Mutations must join that + * generation instead of falling through to synchronous SQL. The cache's + * version fences retain this value when the delayed read publishes. + */ + private boolean queueSharedMutation(UserStorage storage, UserDataChange change, boolean queue, boolean async) { + UserDataManager manager = sharedDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(storage) || manager.isStorageMaintenanceActive()) { + return false; + } + java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); + Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, + () -> applySharedMutation(manager, user.getCache(), change, queue, async)); + if (manager.mustDeferSharedStorageAccess()) { + // Publish read-after-write state synchronously, then defer only persistence. + // An unbound placeholder or retiring cache defers the entire mutation so it + // can join the correct lifecycle generation on the storage worker. + UserDataCache cache = user.getCache(); + if (!cache.tryAddChangeBeforeDeferredSharedFlush(change)) { + return manager.deferSharedStorageWork(mutation); + } + if (queue) manager.dispatchSharedStorageNotification(() -> + user.getPlugin().getUserManager().onChange(user, change.getKey())); + else manager.deferSharedStorageWork(() -> cache.processChangesImmediately(false)); + return true; + } + mutation.run(); + return true; + } + + private void applySharedMutation(UserDataManager manager, UserDataCache cache, UserDataChange change, + boolean queue, boolean async) { + cache.addChange(change, true); + if (queue) { + manager.dispatchSharedStorageNotification(() -> + user.getPlugin().getUserManager().onChange(user, change.getKey())); + } else cache.processChangesImmediately(async); + } + + /** Preserve batched setter semantics through the same cache/storage generation. */ + private boolean queueSharedValues(UserStorage storage, HashMap values) { + UserDataManager manager = sharedDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(storage) || manager.isStorageMaintenanceActive()) { + return false; + } + if (values == null) return false; + if (values.isEmpty()) return true; + ArrayList changes = new ArrayList<>(); + for (java.util.Map.Entry entry : values.entrySet()) { + if (entry.getKey() == null || "uuid".equalsIgnoreCase(entry.getKey()) || entry.getValue() == null) continue; + changes.add(change(entry.getKey(), entry.getValue())); + } + if (changes.isEmpty()) return true; + java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); + Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, () -> { + UserDataCache cache = user.getCache(); + for (UserDataChange change : changes) cache.addChange(change, true); + cache.processChangesImmediately(false); + }); + if (manager.mustDeferSharedStorageAccess()) { + UserDataCache cache = user.getCache(); + if (!cache.tryAddChangesBeforeDeferredSharedFlush(changes)) { + return manager.deferSharedStorageWork(mutation); + } + return manager.deferSharedStorageWork(() -> cache.processChangesImmediately(false)); + } + mutation.run(); + return true; + } + + private UserDataChange change(String key, DataValue value) { + if (value.isInt()) return new UserDataChangeInt(key, value.getInt()); + if (value.isBoolean()) return new UserDataChangeBoolean(key, value.getBoolean()); + return new UserDataChangeString(key, value.getString()); + } + + /** Reject a cross-store request before it can alter the shared cache generation. */ + private void ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(UserStorage storage) { + UserDataManager manager = sharedDataManager(); + if (manager != null && manager.hasSharedSqlBackend() && !manager.usesSharedSqlStorage(storage) + && !manager.isStorageMaintenanceActive()) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns another store"); + } + } + + private UserDataManager sharedDataManager() { + if (user.getPlugin() == null) return null; + UserManager userManager = user.getPlugin().getUserManager(); + return userManager == null ? null : userManager.getDataManager(); + } + + private UserStorage effectiveStorageType() { + UserStorage configured = user.getPlugin().getStorageType(); + UserDataManager manager = sharedDataManager(); + return manager == null ? configured : manager.effectiveStorageType(configured); + } + + private UserDataCache primaryThreadSharedCache(UserStorage storage) { + UserDataManager manager = sharedDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(storage) || !manager.mustDeferSharedStorageAccess()) { + return null; + } + UserDataCache cache = user.getCache(); + if (!cache.hasPublishedStorageSnapshot()) { + throw new IllegalStateException( + "Shared user data is still loading; defer this read until cache population completes"); + } + return cache; + } + + /** Bulk row APIs expose persisted storage, not defaults or pending cache values. */ + private void rejectPrimaryThreadPersistedRowRead(UserStorage storage) { + UserDataManager manager = sharedDataManager(); + if (manager != null && manager.usesSharedSqlStorage(storage) && manager.mustDeferSharedStorageAccess()) { + throw new IllegalStateException( + "Shared persisted user data must be read on a worker; use deferSharedStorageResult"); + } + } public void setStringList(final String key, final ArrayList value) { setStringList(key, value, true); @@ -495,18 +687,20 @@ public void setStringList(final String key, final ArrayList value, boole } public void setValues(HashMap values) { - setValues(user.getPlugin().getStorageType(), values); + setValues(effectiveStorageType(), values); } public void setValues(String key, DataValue value) { HashMap values = new HashMap<>(); values.put(key, value); - setValues(user.getPlugin().getStorageType(), values); + setValues(effectiveStorageType(), values); } - public void setValues(UserStorage storage, HashMap values) { - sqlData.setValues(storage, values); - } + public void setValues(UserStorage storage, HashMap values) { + if (queueSharedValues(storage, values)) return; + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + sqlData.setValues(storage, values); + } public void tempCache() { tempCache = getValues(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java index 28007d0809..cedc27f92d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java @@ -1,21 +1,24 @@ package com.bencodez.advancedcore.api.user; - + import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map.Entry; import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.function.Consumer; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.player.UuidLookup; -import com.bencodez.advancedcore.api.user.usercache.UserDataManager; -import com.bencodez.advancedcore.api.user.validation.UserValidationFactory; -import com.bencodez.advancedcore.api.user.validation.UserValidationService; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.validation.UserValidationFactory; +import com.bencodez.advancedcore.api.user.validation.UserValidationService; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.DataType; @@ -42,66 +45,106 @@ public class UserManager { @Getter private UserValidationService validationService; - public UserManager(AdvancedCorePlugin plugin) { + public UserManager(AdvancedCorePlugin plugin) { this.plugin = plugin; load(); - } - - public void copyColumnData(String columnFromName, String columnToName) { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().copyColumnData(columnFromName, columnToName, DataType.STRING); - } else if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - plugin.getSQLiteUserTable().copyColumnData(columnFromName, columnToName, DataType.STRING); - } - } - - public List getAllColumns() { - UserStorage storage = plugin.getStorageType(); - if (storage.equals(UserStorage.SQLITE)) { - return plugin.getSQLiteUserTable().getColumnsString(); - } - if (storage.equals(UserStorage.MYSQL)) { - return plugin.getMysql().getColumns(); - } - return new ArrayList<>(); - } + } + + /** + * Capture the type and native owner as one observation. During shared-runtime + * replacement the manager publishes this pair atomically with its route; + * callers must not combine plugin.getStorageType() with a later provider read. + */ + private T withActiveStorageOwner(Function operation) { + return dataManager == null ? operation.apply(plugin.getNativeUserStorageOwner()) + : dataManager.withSharedNativeUserStorage(operation); + } + + private UserStorage activeStorageType(AdvancedCorePlugin.UserStorageOwner owner) { + return owner == null ? plugin.getStorageType() : owner.storageType(); + } + + private MySQL activeMysql(AdvancedCorePlugin.UserStorageOwner owner) { + return owner != null && owner.storageType() == UserStorage.MYSQL && owner.mysql() != null + ? owner.mysql() : plugin.getMysql(); + } + + private UserTable activeTable(AdvancedCorePlugin.UserStorageOwner owner) { + return owner != null && owner.storageType() == UserStorage.SQLITE && owner.table() != null + ? owner.table() : plugin.getSQLiteUserTable(); + } + + private AdvancedCorePlugin.UserStorageOwner ownerFor(UserStorage storage, AdvancedCorePlugin.UserStorageOwner owner) { + if (owner != null && owner.storageType() != storage && dataManager != null && dataManager.hasSharedSqlBackend() + && !dataManager.isStorageMaintenanceActive()) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns " + owner.storageType()); + } + return owner != null && owner.storageType() == storage ? owner : null; + } + + public void copyColumnData(String columnFromName, String columnToName) { + withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.MYSQL)) { + activeMysql(owner).copyColumnData(columnFromName, columnToName, DataType.STRING); + } else if (activeStorageType(owner).equals(UserStorage.SQLITE)) { + activeTable(owner).copyColumnData(columnFromName, columnToName, DataType.STRING); + } + return null; + }); + } + + public List getAllColumns() { + return withActiveStorageOwner(owner -> { + UserStorage storage = activeStorageType(owner); + if (storage.equals(UserStorage.SQLITE)) return activeTable(owner).getColumnsString(); + if (storage.equals(UserStorage.MYSQL)) return activeMysql(owner).getColumns(); + return new ArrayList<>(); + }); + } @Deprecated - public HashMap> getAllKeys() { - return getAllKeys(plugin.getStorageType()); - } - - public HashMap> getAllKeys(UserStorage storage) { - if (storage.equals(UserStorage.SQLITE)) { - return plugin.getSQLiteUserTable().getAllQuery(); - } - if (storage.equals(UserStorage.MYSQL)) { - return plugin.getMysql().getAllQuery(); + public HashMap> getAllKeys() { + return withActiveStorageOwner(owner -> getAllKeys(activeStorageType(owner), owner)); + } + + public HashMap> getAllKeys(UserStorage storage) { + return withActiveStorageOwner(owner -> getAllKeys(storage, owner)); + } + + private HashMap> getAllKeys(UserStorage storage, AdvancedCorePlugin.UserStorageOwner owner) { + owner = ownerFor(storage, owner); + if (storage.equals(UserStorage.SQLITE)) { + return activeTable(owner).getAllQuery(); + } + if (storage.equals(UserStorage.MYSQL)) { + return activeMysql(owner).getAllQuery(); } return new HashMap<>(); } - public ArrayList getAllPlayerNames() { - if (plugin.isLoadUserData()) { - ArrayList names = new ArrayList<>(); - if (AdvancedCorePlugin.getInstance().getStorageType().equals(UserStorage.SQLITE)) { - ArrayList data = plugin.getSQLiteUserTable().getNames(); + public ArrayList getAllPlayerNames() { + return withActiveStorageOwner(owner -> { + if (!plugin.isLoadUserData()) return new ArrayList<>(); + UserStorage storage = activeStorageType(owner); + ArrayList names = new ArrayList<>(); + if (storage.equals(UserStorage.SQLITE)) { + ArrayList data = activeTable(owner).getNames(); for (String name : data) { if (name != null && !name.isEmpty() && !name.equalsIgnoreCase("Error getting name")) { names.add(name); } } - } else if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - ArrayList data = ArrayUtils.convert(plugin.getMysql().getNames()); + } else if (storage.equals(UserStorage.MYSQL)) { + ArrayList data = ArrayUtils.convert(activeMysql(owner).getNames()); for (String name : data) { if (name != null && !name.isEmpty() && !name.equalsIgnoreCase("Error getting name")) { names.add(name); } } } - return ArrayUtils.removeDuplicates(names); - } - return new ArrayList<>(); + return ArrayUtils.removeDuplicates(names); + }); } /** @@ -113,38 +156,42 @@ public ArrayList getAllPlayerNames() { * @param perUser BiConsumer called per user with UUID and column list * @param onFinished Consumer called once after all users processed with total */ - public void forEachUserKeys(BiConsumer> perUser, Consumer onFinished) { - UserStorage storage = plugin.getStorageType(); - - if (storage == UserStorage.MYSQL) { - plugin.getMysql().forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { - if (onFinished != null) { - onFinished.accept(count); - } - }); - return; - } - - if (storage == UserStorage.SQLITE) { - plugin.getSQLiteUserTable().forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { - if (onFinished != null) { - onFinished.accept(count); - } - }); - return; - } - - throw new IllegalStateException("User storage is not configured"); + public void forEachUserKeys(BiConsumer> perUser, Consumer onFinished) { + withActiveStorageOwner(owner -> { + UserStorage storage = activeStorageType(owner); + if (storage == UserStorage.MYSQL) { + activeMysql(owner).forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { + if (onFinished != null) { + onFinished.accept(count); + } + }); + return null; + } + if (storage == UserStorage.SQLITE) { + activeTable(owner).forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { + if (onFinished != null) { + onFinished.accept(count); + } + }); + return null; + } + throw new IllegalStateException("User storage is not configured"); + }); } - public ArrayList getAllUUIDs() { - return ArrayUtils.removeDuplicates(getAllUUIDs(plugin.getStorageType())); - } - - public ArrayList getAllUUIDs(UserStorage storage) { - if (plugin.isLoadUserData()) { - if (storage.equals(UserStorage.SQLITE)) { - List cols = plugin.getSQLiteUserTable().getRows(); + public ArrayList getAllUUIDs() { + return withActiveStorageOwner(owner -> ArrayUtils.removeDuplicates(getAllUUIDs(activeStorageType(owner), owner))); + } + + public ArrayList getAllUUIDs(UserStorage storage) { + return withActiveStorageOwner(owner -> getAllUUIDs(storage, owner)); + } + + private ArrayList getAllUUIDs(UserStorage storage, AdvancedCorePlugin.UserStorageOwner owner) { + if (plugin.isLoadUserData()) { + owner = ownerFor(storage, owner); + if (storage.equals(UserStorage.SQLITE)) { + List cols = activeTable(owner).getRows(); ArrayList uuids = new ArrayList<>(); for (Column col : cols) { if (col.getValue().isString()) { @@ -156,7 +203,7 @@ public ArrayList getAllUUIDs(UserStorage storage) { synchronized (obj) { ArrayList uuids = new ArrayList<>(); try { - for (String uuid : plugin.getMysql().getUuids()) { + for (String uuid : activeMysql(owner).getUuids()) { uuids.add(uuid); } } catch (NullPointerException e) { @@ -169,14 +216,12 @@ public ArrayList getAllUUIDs(UserStorage storage) { return new ArrayList<>(); } - public ArrayList getNumbersInColumn(String columnName) { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - return plugin.getMysql().getNumbersInColumn(columnName); - } - if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - return plugin.getSQLiteUserTable().getNumbersInColumn(columnName); - } - return new ArrayList<>(); + public ArrayList getNumbersInColumn(String columnName) { + return withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.MYSQL)) return activeMysql(owner).getNumbersInColumn(columnName); + if (activeStorageType(owner).equals(UserStorage.SQLITE)) return activeTable(owner).getNumbersInColumn(columnName); + return new ArrayList<>(); + }); } public String getOfflineRewardsPath() { @@ -290,10 +335,10 @@ public AdvancedCoreUser getUser(UUID uuid, String playerName) { return new AdvancedCoreUser(plugin, uuid, playerName); } - public void load() { - dataManager = new UserDataManager(AdvancedCorePlugin.getInstance()); - validationService = UserValidationFactory.create(plugin); - } + public void load() { + dataManager = new UserDataManager(plugin); + validationService = UserValidationFactory.create(plugin); + } public void onChange(AdvancedCoreUser user, String... keys) { for (UserDataChanged change : userDataChange) { @@ -363,12 +408,12 @@ public void onStartUp(AdvancedCoreUser user) { getDataManager().clearCache(); } - public void removeAllKeyValues(String key, DataType type) { - if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - plugin.getSQLiteUserTable().wipeColumnData(key, type); - } else if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().wipeColumnData(key, type); - } + public void removeAllKeyValues(String key, DataType type) { + withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.SQLITE)) activeTable(owner).wipeColumnData(key, type); + else if (activeStorageType(owner).equals(UserStorage.MYSQL)) activeMysql(owner).wipeColumnData(key, type); + return null; + }); } public boolean userExistStored(String name) { @@ -390,12 +435,15 @@ public boolean userExistStored(String name) { return false; } - public void removeUUID(UUID key) { - if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - plugin.getSQLiteUserTable().delete(new Column("uuid", new DataValueString(key.toString()))); - } else if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().deletePlayer(key.toString()); - } + public void removeUUID(UUID key) { + withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.SQLITE)) { + activeTable(owner).delete(new Column("uuid", new DataValueString(key.toString()))); + } else if (activeStorageType(owner).equals(UserStorage.MYSQL)) { + activeMysql(owner).deletePlayer(key.toString()); + } + return null; + }); } public boolean userExist(String name) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java index c58b17fc9d..6b36c66390 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java @@ -3,11 +3,13 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Map.Entry; +import java.util.Objects; import java.util.Queue; import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; @@ -18,16 +20,23 @@ import lombok.Getter; public class UserDataCache { - @Getter - private HashMap cache; - + @Getter private HashMap cache; private Queue cachedChanges; - private final UserDataManager manager; + private long snapshotVersion; + private long replacementVersion; + private boolean storedDataPresent; + private boolean storageSnapshotPublished; + private final HashMap changedAt = new HashMap<>(); + private final HashMap persistedAt = new HashMap<>(); + private final HashMap inFlightValues = new HashMap<>(); private boolean scheduled = false; + private boolean removing; private int inFlightBatches = 0; - @Getter - private UUID uuid; + private volatile Consumer> sharedStorageWriter; + private Thread sharedBatchThread; + private volatile Consumer sharedFlushGate; + @Getter private UUID uuid; public UserDataCache(UserDataManager manager, UUID uuid) { this.uuid = uuid; @@ -36,77 +45,146 @@ public UserDataCache(UserDataManager manager, UUID uuid) { cache = new HashMap<>(); } - public synchronized void addChange(UserDataChange change, boolean queue) { - if (change == null || cache == null || cachedChanges == null) { - return; + private void initializeSharedStorage() { + synchronized (this) { if (sharedFlushGate != null || uuid == null || cachedChanges == null) return; } + if (manager != null) manager.initializeSharedCache(this); + } + + public void addChange(UserDataChange change, boolean queue) { + initializeSharedStorage(); + Consumer gate; + synchronized (this) { + gate = sharedFlushGate; + if (gate == null) { addChangeInternal(change, queue); return; } + } + gate.accept(() -> addChangeInternal(change, queue)); + } + + /** + * Publish into an already bound cache without waiting for its user gate. The + * cache monitor serializes this with retirement markers: a change accepted + * first is included in the following flush, while a transition that marks the + * cache first makes the caller defer the complete mutation instead. + */ + public synchronized boolean tryAddChangeBeforeDeferredSharedFlush(UserDataChange change) { + return tryAddChangesBeforeDeferredSharedFlush(java.util.List.of(change)); + } + + /** Atomically publish one complete caller batch against cache retirement. */ + public synchronized boolean tryAddChangesBeforeDeferredSharedFlush(Iterable changes) { + if (sharedFlushGate == null || removing || uuid == null || cache == null || cachedChanges == null) return false; + for (UserDataChange change : changes) addChangeInternal(change, true); + return true; + } + + private synchronized void addChangeInternal(UserDataChange change, boolean queue) { + if (change != null && sharedStorageWriter != null && (cache == null || cachedChanges == null)) { + throw new IllegalStateException("Shared user cache is retired"); } + // A manager-wide retirement must never make a caller believe that its + // queued mutation was accepted when it was not. The manager normally + // admits writers before this point; this guard also covers a re-entrant + // listener running as the cache is being retired. + if (removing) throw new IllegalStateException("Shared user cache is retiring"); + if (change == null || cache == null || cachedChanges == null) return; cache.put(change.getKey(), change.toUserDataValue()); + changedAt.put(change.getKey(), ++snapshotVersion); if (queue) { cachedChanges.add(change); - if (!scheduled) { - scheduleChanges(); - } + if (!scheduled) scheduleChanges(); } } - public synchronized UserDataCache cache() { - if (uuid != null && cache != null) { - AdvancedCoreUser user = getUser(); - ArrayList keys = user.getUserData().getKeys(); - HashMap data = user.getUserData().getValues(); - ArrayList changedKeys = new ArrayList<>(); - for (UserDataKey dataKey : manager.getKeys()) { - String key = dataKey.getKey(); - keys.remove(key); - if (data.containsKey(key)) { - DataValue dataValue = data.get(key); - manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " - + uuid.toString() + ", value: " + dataValue.toString()); - try { - if (cache.containsKey(key) && !cache.get(key).toString().equals(dataValue.toString())) { - changedKeys.add(key); - } - } catch (Exception e) { - manager.getPlugin().debug(e); - } - cache.put(key, dataValue); - } else { - manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + uuid.toString()); - cache.put(key, dataKey.getDefault()); - } - } - if (!changedKeys.isEmpty()) { - manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); - } - if (keys.size() > 0) { - manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); - } - } + public UserDataCache cache() { + cacheInternal(true); return this; } - public synchronized void clearCache() { - if (hasChangesToProcess()) { - processChanges(); + UserDataCache cacheInternal(boolean notify) { + refreshInternal(notify); + return this; + } + + ArrayList refreshInternal(boolean notify) { + initializeSharedStorage(); + UUID currentUuid; + long expectedVersion; + HashMap before; + synchronized (this) { + if (uuid == null || cache == null) return new ArrayList<>(); + currentUuid = uuid; + expectedVersion = snapshotVersion; + before = new HashMap<>(cache); } - if (cache != null) { - cache.clear(); + AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); + ArrayList keys = user.getUserData().getKeys(); + HashMap data = user.getUserData().getValues(); + boolean refreshedStoredDataPresent = !keys.isEmpty() || !data.isEmpty(); + // Primary-thread public reads cannot fall back to SQL once the shared + // runtime is bound. Retain arbitrary persisted columns (for example + // VotingPlugin's dynamic VoteShopLimit keys) and layer registered defaults + // only where storage omitted a known key. + HashMap refreshed = new HashMap<>(data); + for (UserDataKey dataKey : manager.getKeys()) { + String key = dataKey.getKey(); + keys.remove(key); + DataValue dataValue = data.containsKey(key) ? data.get(key) : dataKey.getDefault(); + if (data.containsKey(key)) manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " + currentUuid + ", value: " + dataValue); + else manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + currentUuid); + refreshed.put(key, dataValue); } + HashMap published; + synchronized (this) { + // A concurrent cache eviction is an expected legacy lifecycle outcome. + // It must not turn a completed storage read into a failed cache request. + if (uuid == null || cache == null) return new ArrayList<>(); + published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid, + refreshedStoredDataPresent); + } + ArrayList changedKeys = new ArrayList<>(); + for (Entry entry : published.entrySet()) { + DataValue prior = before.get(entry.getKey()); + if (prior != null && entry.getValue() != null && !prior.toString().equals(entry.getValue().toString())) changedKeys.add(entry.getKey()); + } + if (notify && !changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); + if (!keys.isEmpty()) manager.getPlugin().devDebug("Caching additional keys: " + ArrayUtils.makeStringList(keys)); + return changedKeys; } - public synchronized void clearChanges() { - if (hasChangesToProcess()) { - processChanges(); - } + public void clearCache() { + if (manager != null && manager.deferSharedStorageWork(this::clearCacheNow)) return; + clearCacheNow(); } /** * Drains every queued or in-flight cache batch before running a synchronous - * write. The action runs while holding the cache monitor, preventing an old - * queued value from being persisted after a durable replacement. + * write. Shared storage keeps the complete drain and replacement under the + * per-user lifecycle gate, preventing a newer cache operation from interleaving. */ public void flushChangesAndRun(Runnable action) { if (action == null) return; + initializeSharedStorage(); + Consumer gate; + synchronized (this) { gate = sharedFlushGate; } + if (gate != null) { + ArrayList notifications = new ArrayList<>(); + try { + gate.accept(() -> { + while (true) { + Runnable notification = processChangesInternal(true); + if (notification != null) notifications.add(notification); + synchronized (this) { + if (cachedChanges != null && !cachedChanges.isEmpty()) continue; + action.run(); + break; + } + } + }); + } finally { + for (Runnable notification : notifications) notification.run(); + } + return; + } while (true) { processChanges(); synchronized (this) { @@ -125,164 +203,348 @@ public void flushChangesAndRun(Runnable action) { } } - public void displayCache() { - manager.getPlugin().devDebug(displayCacheStringList().toString()); + private void clearCacheNow() { + initializeSharedStorage(); + Consumer gate; + synchronized (this) { + gate = sharedFlushGate; + if (gate == null) { + if (hasChangesToProcess()) processChanges(); + if (cache != null) { cache.clear(); recordSnapshotReplacement(); } + return; + } + } + java.util.concurrent.atomic.AtomicReference notification = new java.util.concurrent.atomic.AtomicReference<>(); + gate.accept(() -> { + notification.set(processChangesInternal(true)); + synchronized (this) { if (cache != null) { cache.clear(); recordSnapshotReplacement(); } } + }); + Runnable callback = notification.get(); + if (callback != null) callback.run(); + } + + public void clearChanges() { + Runnable callback = clearChangesForRefresh(); + if (callback != null) callback.run(); } + /** + * Flush queued changes for a cache refresh, but leave the user-data callback to + * the caller. Cache refreshes run under shared per-user admission, while a + * callback is permitted to remove that user and therefore needs exclusive + * admission. Running it here would attempt an unsupported lock upgrade. + */ + Runnable clearChangesForRefresh() { + if (!hasChangesToProcess()) return null; + if (manager != null && manager.deferSharedStorageWork(this::clearChangesAndNotify)) return null; + return clearChangesNow(); + } + + private void clearChangesAndNotify() { + Runnable callback = clearChangesNow(); + if (callback != null) callback.run(); + } + + private Runnable clearChangesNow() { + if (!hasChangesToProcess()) return null; + Consumer gate; + synchronized (this) { gate = sharedFlushGate; } + if (gate == null) return processChangesInternal(false); + java.util.concurrent.atomic.AtomicReference notification = new java.util.concurrent.atomic.AtomicReference<>(); + gate.accept(() -> notification.set(processChangesInternal(true))); + return notification.get(); + } + + public void displayCache() { manager.getPlugin().devDebug(displayCacheStringList().toString()); } public synchronized ArrayList displayCacheStringList() { ArrayList list = new ArrayList<>(); list.add("Current cache for " + uuid + ": "); - if (cache == null) { - return list; - } - for (Entry entry : getCache().entrySet()) { - if (entry.getValue().isBoolean()) { - list.add(entry.getKey() + "=" + entry.getValue().getBoolean()); - } else if (entry.getValue().isString()) { - list.add(entry.getKey() + "=" + entry.getValue().getString()); - } else if (entry.getValue().isInt()) { - list.add(entry.getKey() + "=" + entry.getValue().getInt()); - } + if (cache == null) return list; + for (Entry entry : cache.entrySet()) { + if (entry.getValue().isBoolean()) list.add(entry.getKey() + "=" + entry.getValue().getBoolean()); + else if (entry.getValue().isString()) list.add(entry.getKey() + "=" + entry.getValue().getString()); + else if (entry.getValue().isInt()) list.add(entry.getKey() + "=" + entry.getValue().getInt()); } return list; } public void dump() { + if (manager != null && manager.deferSharedStorageWork(this::dumpNow)) return; + dumpNow(); + } + + private void dumpNow() { while (true) { processChanges(); synchronized (this) { while (inFlightBatches > 0) { - try { - wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } + try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } - if (cachedChanges != null && !cachedChanges.isEmpty()) { - continue; - } - cache = null; - cachedChanges = null; - uuid = null; - scheduled = false; - return; + if (cachedChanges != null && !cachedChanges.isEmpty()) continue; + recordSnapshotReplacement(); cache = null; cachedChanges = null; uuid = null; scheduled = false; return; } } } - public AdvancedCoreUser getUser() { - return manager.getPlugin().getUserManager().getUser(uuid, false); + public AdvancedCoreUser getUser() { return manager.getPlugin().getUserManager().getUser(uuid, false); } + public synchronized boolean hasCache() { return cache != null && !cache.isEmpty(); } + public synchronized boolean hasStoredData() { return storedDataPresent; } + public synchronized boolean hasPublishedStorageSnapshot() { return storageSnapshotPublished; } + public synchronized HashMap snapshot() { + return cache == null ? new HashMap<>() : new HashMap<>(cache); + } + public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } + public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } + + public synchronized void ensureNoLegacyBatchForSharedBinding() { + if (sharedStorageWriter == null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); + } + + public synchronized void configureSharedStorage(Consumer> writer, Consumer gate) { + if (sharedFlushGate != null && sharedFlushGate != gate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); + if (sharedFlushGate == null && gate != null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); + setSharedStorageWriter(writer); + sharedFlushGate = gate; } - public synchronized boolean hasCache() { - return cache != null && !cache.isEmpty(); + public synchronized void setSharedStorageWriter(Consumer> writer) { + if (uuid == null || cachedChanges == null) throw new IllegalStateException("Shared user cache is retired"); + sharedStorageWriter = java.util.Objects.requireNonNull(writer, "writer"); } - public synchronized boolean hasChangesToProcess() { - return cachedChanges != null && !cachedChanges.isEmpty(); + public synchronized void retireAfterSharedFlush() { + if (inFlightBatches != 0 || (cachedChanges != null && !cachedChanges.isEmpty())) throw new IllegalStateException("Shared user cache has unflushed work"); + recordSnapshotReplacement(); cache = null; cachedChanges = null; uuid = null; scheduled = false; } - public synchronized boolean isCached(String key) { - return cache != null && cache.containsKey(key); + /** Prevent reentrant change listeners from resurrecting a cache being removed. */ + public synchronized void beginRemoval() { removing = true; } + + /** Reopen a cache when a manager-wide removal could not flush this cache. */ + public synchronized void cancelRemoval() { + if (cache != null && cachedChanges != null) removing = false; } public void processChanges() { - UUID currentUuid; + initializeSharedStorage(); + Runnable notification = processChangesInternal(false); + if (notification != null) notification.run(); + } + + /** + * Persist one shared-runtime batch while returning its change notification to + * the runtime. The runtime delivers it only after releasing lifecycle and + * per-user admission, so listeners may safely request exclusive user work. + */ + public Runnable processChangesForSharedRuntime() { + initializeSharedStorage(); + return processChangesInternal(false); + } + + /** Flush now when blocking is allowed, otherwise preserve ordering on the cache worker. */ + public void processChangesImmediately(boolean async) { + if (async) { + processChangesAsync(); + return; + } + if (manager != null && manager.hasSharedSqlBackend()) { + if (manager.deferSharedStorageWork(() -> processChangesImmediately(false))) return; + initializeSharedStorage(); + Runnable notification = processChangesInternal(false); + if (notification != null) manager.dispatchSharedStorageNotification(notification); + return; + } + processChanges(); + } + + private Runnable processChangesInternal(boolean admitted) { + UUID currentUuid = null; + Consumer> writer = null; + Consumer gate; ArrayList changes = new ArrayList<>(); + HashMap persistedMutationVersions = new HashMap<>(); + boolean legacyAdmission = false; synchronized (this) { - currentUuid = uuid; - if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) { - return; - } - UserDataChange change; - while ((change = cachedChanges.poll()) != null) { - changes.add(change); + gate = admitted ? null : sharedFlushGate; + if (gate == null) { + writer = sharedStorageWriter; + if (writer != null) { + if (sharedBatchThread == Thread.currentThread()) throw new IllegalStateException("Cannot flush a shared cache from its own change notification"); + while (inFlightBatches > 0) { + try { wait(); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException("Interrupted while draining user changes", interrupted); } + } + } + currentUuid = uuid; + if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) return null; + if (writer == null && manager != null) { + manager.beginLegacyCacheBatch(); + legacyAdmission = true; + } + UserDataChange change; + while ((change = cachedChanges.poll()) != null) changes.add(change); + for (UserDataChange queuedChange : changes) { + Long version = changedAt.get(queuedChange.getKey()); + if (version != null) persistedMutationVersions.put(queuedChange.getKey(), version); + } + inFlightValues.clear(); + try { + for (UserDataChange changeEntry : changes) inFlightValues.put(changeEntry.getKey(), changeEntry.toUserDataValue()); + } catch (RuntimeException | Error preparationFailure) { + inFlightValues.clear(); + requeueChanges(changes); + if (legacyAdmission) manager.endLegacyCacheBatch(); + throw preparationFailure; + } + inFlightBatches++; + if (writer != null) sharedBatchThread = Thread.currentThread(); } - inFlightBatches++; } - + if (gate != null) { + java.util.concurrent.atomic.AtomicReference notification = new java.util.concurrent.atomic.AtomicReference<>(); + gate.accept(() -> notification.set(processChangesInternal(true))); + return notification.get(); + } boolean persisted = false; + AdvancedCoreUser changedUser = null; + String[] changedKeys = null; try { manager.getPlugin().extraDebug("Processing changes for " + currentUuid + ", Changes: " + changes.size()); AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); HashMap values = new HashMap<>(); ArrayList keys = new ArrayList<>(); - for (UserDataChange change : changes) { - values.put(change.getKey(), change.toUserDataValue()); - keys.add(change.getKey()); - } - if (!values.isEmpty()) { - user.getUserData().setValues(values); - } + for (UserDataChange change : changes) { values.put(change.getKey(), change.toUserDataValue()); keys.add(change.getKey()); } + if (!values.isEmpty()) { if (writer == null) user.getUserData().setValues(values); else writer.accept(values); } persisted = true; - manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(keys)); - for (UserDataChange change : changes) { - change.dump(); + synchronized (this) { + if (!values.isEmpty()) storedDataPresent = true; + if (cache != null) { + for (UserDataChange persistedChange : changes) { + Long persistedVersion = persistedMutationVersions.get(persistedChange.getKey()); + Long queuedVersion = changedAt.get(persistedChange.getKey()); + // A setter may have queued a newer value while this batch was in + // storage. Keep that visible value until its own batch succeeds. + // A public updateCache() replacement, however, is a storage snapshot + // rather than another mutation. It clears changedAt(), so reconcile + // that stale snapshot with this completed write instead of leaving a + // value visible which will never be persisted. + if (queuedVersion == null || Objects.equals(persistedVersion, queuedVersion)) { + cache.put(persistedChange.getKey(), persistedChange.toUserDataValue()); + } + } + } + // The in-memory values now represent the successful write. Mark that + // replacement so an older in-flight read cannot overwrite them. + persistedMutationVersions.forEach((key, version) -> persistedAt.put(key, version)); + persistedMutationVersions.forEach((key, version) -> { + if (version.equals(changedAt.get(key))) changedAt.remove(key); + }); } + changedUser = user; + changedKeys = ArrayUtils.convert(keys); } catch (RuntimeException | Error e) { - if (!persisted) { - requeueChanges(changes); - } + if (!persisted) requeueChanges(changes); throw e; } finally { finishInFlightBatch(); + if (legacyAdmission) manager.endLegacyCacheBatch(); } + // UserDataChanged callbacks may clear this cache. Do not expose the cache + // while its active shared batch marker is still set. + if (!persisted) return null; + AdvancedCoreUser notifyUser = changedUser; + String[] notifyKeys = changedKeys; + return () -> { + manager.getPlugin().getUserManager().onChange(notifyUser, notifyKeys); + for (UserDataChange change : changes) change.dump(); + }; } private synchronized void requeueChanges(ArrayList changes) { - if (changes == null || changes.isEmpty() || cachedChanges == null) { - return; - } + if (changes == null || changes.isEmpty() || cachedChanges == null) return; Queue restored = new ConcurrentLinkedQueue<>(); - restored.addAll(changes); - restored.addAll(cachedChanges); - cachedChanges = restored; + restored.addAll(changes); restored.addAll(cachedChanges); cachedChanges = restored; } private synchronized void finishInFlightBatch() { if (inFlightBatches > 0) { inFlightBatches--; + if (inFlightBatches == 0) inFlightValues.clear(); + if (sharedBatchThread == Thread.currentThread()) sharedBatchThread = null; } notifyAll(); } - public void processChangesAsync() { - if (uuid != null && hasChangesToProcess()) { - manager.getPlugin().getTimer().execute(this::processChanges); - } - } + public void processChangesAsync() { if (uuid != null && hasChangesToProcess()) manager.getPlugin().getTimer().execute(this::processChanges); } private synchronized void scheduleChanges() { - if (scheduled || cachedChanges == null || cachedChanges.isEmpty()) { - return; - } + if (scheduled || cachedChanges == null || cachedChanges.isEmpty()) return; manager.getPlugin().debug("Schedule changes"); scheduled = true; try { manager.getTimer().schedule(() -> { - try { - processChanges(); - } catch (Exception e) { - manager.getPlugin().debug(e); - } finally { - onScheduledFlushComplete(); - } + try { processChanges(); } catch (Exception e) { manager.getPlugin().debug(e); } + finally { onScheduledFlushComplete(); } }, 3, TimeUnit.SECONDS); - } catch (RejectedExecutionException e) { - scheduled = false; - manager.getPlugin().debug(e); - } + } catch (RejectedExecutionException e) { scheduled = false; manager.getPlugin().debug(e); } } private synchronized void onScheduledFlushComplete() { scheduled = false; - if (cachedChanges != null && !cachedChanges.isEmpty()) { - scheduleChanges(); - } + if (cachedChanges != null && !cachedChanges.isEmpty()) scheduleChanges(); } public synchronized void updateCache(HashMap tempCache) { cache = tempCache == null ? new HashMap<>() : new HashMap<>(tempCache); + storageSnapshotPublished = true; + recordSnapshotReplacement(); + } + + public synchronized void updateCachePreservingPending(HashMap storageValues) { + HashMap refreshed = storageValues == null ? new HashMap<>() : new HashMap<>(storageValues); + storedDataPresent = !refreshed.isEmpty(); + refreshed.putAll(inFlightValues); + if (cachedChanges != null) for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); + cache = refreshed; + storageSnapshotPublished = true; + recordSnapshotReplacement(); + } + + public synchronized long getSharedSnapshotVersion() { return snapshotVersion; } + + public synchronized HashMap updateSharedSnapshot(HashMap values, + long expectedVersion) { + return updateSharedSnapshot(values, expectedVersion, uuid, values != null && !values.isEmpty()); + } + + private synchronized HashMap updateSharedSnapshot(HashMap values, + long expectedVersion, UUID expectedUuid, boolean snapshotStoredDataPresent) { + if (cache == null || uuid == null || !uuid.equals(expectedUuid)) throw new IllegalStateException("Shared user cache changed while loading"); + if (expectedVersion < 0 || expectedVersion > snapshotVersion) throw new IllegalArgumentException("Invalid cache snapshot version"); + if (replacementVersion > expectedVersion) return new HashMap<>(cache); + storedDataPresent = snapshotStoredDataPresent; + HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); + changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); + persistedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); + cache = merged; + storageSnapshotPublished = true; + recordSnapshotReplacement(); + persistedAt.clear(); + return new HashMap<>(cache); + } + + private void recordSnapshotReplacement() { + replacementVersion = ++snapshotVersion; + // A storage/read snapshot replaces unqueued local observations, but it must + // not erase the version fence for a mutation that is still waiting in the + // write queue. Otherwise an older in-flight batch can overwrite that newer + // visible value when it completes. + changedAt.keySet().removeIf(key -> cachedChanges == null + || cachedChanges.stream().noneMatch(change -> key.equals(change.getKey()))); + if (cache != null && cachedChanges != null) { + for (UserDataChange change : cachedChanges) { + cache.put(change.getKey(), change.toUserDataValue()); + } + } } } 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 6abd7838e1..da3d89329e 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 @@ -1,44 +1,599 @@ package com.bencodez.advancedcore.api.user.usercache; import java.util.ArrayList; +import java.util.Objects; +import java.util.Map.Entry; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; 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.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Consumer; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyBoolean; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyString; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; import com.bencodez.simpleapi.debug.DebugLevel; +import com.bencodez.simpleapi.array.ArrayUtils; import lombok.Getter; public class UserDataManager { - @Getter - private ArrayList keys; + @Getter private ArrayList keys; + @Getter private ArrayList intColumns; + @Getter private ArrayList booleanColumns; + @Getter private AdvancedCorePlugin plugin; + @Getter private ScheduledExecutorService timer; + @Getter private ConcurrentHashMap userDataCache; - @Getter - private ArrayList intColumns; + private volatile Consumer sharedCacheInitializer; + private volatile Consumer sharedCacheRemovalListener; + private volatile SharedSqlRoute sharedSqlRoute; + private volatile SharedUserDataRuntime sharedRuntime; + /** A replacement is serialized with retirement so a native owner cannot be closed mid-flush. */ + private volatile CompletionStage sharedRuntimeReplacement; + private volatile boolean sharedRuntimeReplacing; + /** True from retirement admission until its native-owner callback has finished. */ + private volatile boolean sharedRuntimeRetiring; + /** The one in-flight retirement, retained so lifecycle callers can await it safely. */ + private volatile CompletionStage sharedRuntimeRetirement; + private final AtomicReference lastDeferredStorageFailure = new AtomicReference<>(); + private final Object sharedBindingAdmission = new Object(); + /** Explicit converter-only bypass for the legacy storage adapter. */ + private final ThreadLocal storageMaintenanceDepth = ThreadLocal.withInitial(() -> 0); + private boolean sharedBindingTransition; + private int legacyBatches; + private final ReentrantReadWriteLock cacheMapLifecycle = new ReentrantReadWriteLock(true); + private final Set sharedCachePopulations = ConcurrentHashMap.newKeySet(); + private final Set completedSharedCachePopulations = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap sharedCachePopulationStates = new ConcurrentHashMap<>(); - @Getter - private ArrayList booleanColumns; + private static final class SharedCachePopulationState { + private long generation; + private int activePopulations; + } + + private record SharedCachePopulation(UUID uuid, long generation, boolean deferred) {} + + /** Admit shared cache-map population while excluding whole-map clear/replace. */ + public final T withCacheMapReadAdmission(Supplier operation) { + Objects.requireNonNull(operation, "operation"); + cacheMapLifecycle.readLock().lock(); + try { return operation.get(); } + finally { cacheMapLifecycle.readLock().unlock(); } + } + + private record SharedSqlRoute(SqlUserBackend backend, AdvancedCorePlugin.UserStorageOwner nativeOwner, + Consumer lifecycleGate, BiConsumer gate, + BiConsumer exclusiveGate) { + SharedSqlRoute { + Objects.requireNonNull(backend, "backend"); + Objects.requireNonNull(lifecycleGate, "lifecycleGate"); + Objects.requireNonNull(gate, "gate"); + Objects.requireNonNull(exclusiveGate, "exclusiveGate"); + } + } + + /** One shared owner for the existing map, including caches created by legacy callers. */ + public final synchronized void bindSharedCacheInitializer(Consumer initializer) { + Objects.requireNonNull(initializer, "initializer"); + if (sharedCacheInitializer != null && sharedCacheInitializer != initializer) { + throw new IllegalStateException("User data manager already belongs to another shared runtime"); + } + sharedCacheInitializer = initializer; + } + + /** Register lifecycle cleanup for manager-driven cache eviction. */ + public final synchronized void bindSharedCacheRemovalListener(Consumer listener) { + Objects.requireNonNull(listener, "listener"); + if (sharedCacheRemovalListener != null && sharedCacheRemovalListener != listener) { + throw new IllegalStateException("User data manager already belongs to another shared runtime"); + } + sharedCacheRemovalListener = listener; + } + + void initializeSharedCache(UserDataCache cache) { + Consumer initializer = sharedCacheInitializer; + if (initializer != null) { + if (Thread.holdsLock(cache)) throw new IllegalStateException("Cannot attach shared storage while holding the cache monitor"); + initializer.accept(cache); + } + } + + /** + * Close legacy-batch admission before a shared route is published. This is a + * fail-fast transition: constructors never wait for an old provider write. + */ + public final void beginSharedBindingTransition() { + synchronized (sharedBindingAdmission) { + if (sharedBindingTransition) throw new IllegalStateException("Shared user binding is already in progress"); + if (sharedSqlRoute != null) throw new IllegalStateException("Shared SQL backend is already bound"); + sharedBindingTransition = true; + if (legacyBatches != 0) { + sharedBindingTransition = false; + throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); + } + } + } + + public final void endSharedBindingTransition() { + synchronized (sharedBindingAdmission) { + sharedBindingTransition = false; + sharedBindingAdmission.notifyAll(); + } + } + + /** Admission used by UserDataCache before it selects the legacy provider. */ + final void beginLegacyCacheBatch() { + synchronized (sharedBindingAdmission) { + if (sharedBindingTransition || sharedSqlRoute != null) { + throw new IllegalStateException("Legacy user storage is retired by the shared runtime"); + } + legacyBatches++; + } + } - @Getter - private AdvancedCorePlugin plugin; + final void endLegacyCacheBatch() { + synchronized (sharedBindingAdmission) { + if (legacyBatches <= 0) throw new IllegalStateException("Legacy user batch admission is unbalanced"); + legacyBatches--; + sharedBindingAdmission.notifyAll(); + } + } + + /** Publish backend and per-user lifecycle admission as one volatile immutable route. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate) { + bindSharedSqlBackend(backend, gate, gate); + } + + /** Publish the shared read and exclusive per-user lifecycle admissions together. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate, + BiConsumer exclusiveGate) { + bindSharedSqlBackend(backend, operation -> operation.run(), gate, exclusiveGate); + } + + /** Publish native bulk admission with the per-user route in one immutable binding. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Consumer lifecycleGate, + BiConsumer gate, BiConsumer exclusiveGate) { + Objects.requireNonNull(lifecycleGate, "lifecycleGate"); + AdvancedCorePlugin.UserStorageOwner owner = plugin == null ? null : plugin.getNativeUserStorageOwner(); + if (owner != null && owner.storageType() != backend.storageType()) { + throw new IllegalStateException("Native user storage owner does not match the shared backend"); + } + sharedSqlRoute = new SharedSqlRoute(backend, owner, lifecycleGate, gate, exclusiveGate); + } + + /** Compatibility overload for adapters that only need the global lifecycle gate. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Consumer gate) { + Objects.requireNonNull(gate, "gate"); + bindSharedSqlBackend(backend, gate, (uuid, operation) -> gate.accept(operation), + (uuid, operation) -> gate.accept(operation)); + } + + public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { + SharedSqlRoute route = sharedSqlRoute; + if (route != null && route.backend() == expected) sharedSqlRoute = null; + } + + public final boolean hasSharedSqlBackend() { return sharedSqlRoute != null; } + + /** Whether the active shared writer owns the requested physical store. */ + public final boolean usesSharedSqlStorage(com.bencodez.advancedcore.api.user.UserStorage storage) { + SharedSqlRoute route = sharedSqlRoute; + return route != null && route.backend().storageType() == storage; + } + + /** Prefer the immutable active shared route over a newly reloaded option. */ + public final UserStorage effectiveStorageType(UserStorage configured) { + SharedSqlRoute route = sharedSqlRoute; + return route == null ? Objects.requireNonNull(configured, "configured") : route.backend().storageType(); + } + + /** + * The native owner captured with the current shared route. Public bulk APIs + * use this rather than separately reading a route type and mutable plugin + * owner field during an asynchronous replacement. + */ + public final AdvancedCorePlugin.UserStorageOwner sharedNativeUserStorageOwner() { + SharedSqlRoute route = sharedSqlRoute; + return route == null ? null : route.nativeOwner(); + } + + /** + * Resolve the native owner only after lifecycle read admission. A replacement + * takes the matching write admission before it can publish a route or close + * the previous provider. Main-thread callers fail instead of waiting on SQL. + */ + public final T withSharedNativeUserStorage( + java.util.function.Function operation) { + Objects.requireNonNull(operation, "operation"); + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null || isStorageMaintenanceActive()) { + return operation.apply(admission == null + ? (plugin == null ? null : plugin.getNativeUserStorageOwner()) : admission.nativeOwner()); + } + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Shared user storage must run on a worker thread"); + } + AtomicReference result = new AtomicReference<>(); + admission.lifecycleGate().accept(() -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null || current.lifecycleGate() != admission.lifecycleGate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for bulk admission"); + } + if (current.nativeOwner() == null) throw new IllegalStateException("Shared native user storage is unavailable"); + result.set(operation.apply(current.nativeOwner())); + }); + return result.get(); + } + + /** + * Run an explicit storage-maintenance operation behind the shared runtime's + * write barrier. Calls made by the operation may target a non-current store + * only while this scoped flag is active; ordinary adapters remain protected + * from cross-store writes. + */ + public final void runStorageMaintenance(Runnable operation) { + Objects.requireNonNull(operation, "operation"); + SharedUserDataRuntime runtime = sharedRuntime; + if (runtime == null && sharedRuntimeRetiring) { + throw new IllegalStateException("Shared user runtime retirement is still in progress"); + } + Runnable guarded = () -> { + int previous = storageMaintenanceDepth.get(); + storageMaintenanceDepth.set(previous + 1); + try { operation.run(); } + finally { + if (previous == 0) storageMaintenanceDepth.remove(); + else storageMaintenanceDepth.set(previous); + } + }; + if (runtime == null) guarded.run(); + else runtime.runStorageMaintenance(guarded); + } + + /** + * Remove one user through the shared runtime when available, preserving the + * flush-delete-retire ordering required to avoid recreating rows. + */ + public final void removeUserData(UUID uuid, UserStorage storage) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(storage, "storage"); + if (deferSharedStorageWork(() -> removeUserDataNow(uuid, storage))) return; + removeUserDataNow(uuid, storage); + } + + private void removeUserDataNow(UUID uuid, UserStorage storage) { + SharedUserDataRuntime runtime = sharedRuntime; + if (runtime != null && !runtime.isClosed()) { + if (!runtime.backend().storageType().equals(storage)) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns " + runtime.backend().storageType()); + } + runtime.remove(uuid); + return; + } + withSharedSqlBackend(uuid, (sharedStorage, target) -> { + if (!sharedStorage.equals(storage)) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns another store"); + } + target.delete(sharedStorage); + return null; + }); + } + + /** True only inside a runtime-exclusive explicit storage maintenance action. */ + public final boolean isStorageMaintenanceActive() { return storageMaintenanceDepth.get() > 0; } + + /** Register the one runtime that owns the shared route for this manager. */ + public final synchronized void bindSharedRuntime(SharedUserDataRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + if (sharedRuntimeRetiring) { + throw new IllegalStateException("Shared user runtime retirement is still in progress"); + } + if (sharedRuntime != null && !sharedRuntime.isClosed()) { + throw new IllegalStateException("Shared user runtime is already bound"); + } + sharedRuntime = runtime; + } + + public final boolean hasSharedRuntime() { + SharedUserDataRuntime runtime = sharedRuntime; + return runtime != null && !runtime.isClosed(); + } + + /** + * Replace the active shared backend on this manager's worker. The runtime + * flushes and retires the old cache generation before publishing the new + * route, so callers may safely prepare a replacement without blocking a + * Bukkit thread. A concurrent shutdown wins safely: the replacement then + * completes exceptionally rather than touching a retiring provider. + */ + public final CompletionStage replaceSharedSqlBackendAsync(SqlUserBackend replacement) { + return replaceSharedSqlBackendAsync(replacement, () -> {}); + } + + /** + * Asynchronously replace the backend and publish native-owner state before + * exposing completion to shutdown callers. The callback must be short and + * non-blocking; it runs on this manager's worker while the replacement route + * is still protected from a concurrent retirement admission. + */ + public final CompletionStage replaceSharedSqlBackendAsync(SqlUserBackend replacement, Runnable afterReplacement) { + Objects.requireNonNull(replacement, "replacement"); + Objects.requireNonNull(afterReplacement, "afterReplacement"); + SharedUserDataRuntime runtime; + CompletableFuture completion = new CompletableFuture<>(); + synchronized (this) { + runtime = sharedRuntime; + if (runtime == null || sharedRuntimeRetiring) { + completion.completeExceptionally(new IllegalStateException( + "Shared user storage is retiring and cannot be reloaded")); + return completion; + } + if (sharedRuntimeReplacement != null && !sharedRuntimeReplacement.toCompletableFuture().isDone()) { + completion.completeExceptionally(new IllegalStateException("Shared user storage reload is already in progress")); + return completion; + } + sharedRuntimeReplacement = completion; + sharedRuntimeReplacing = true; + } + try { + timer.execute(() -> { + Throwable replacementFailure = null; + try { + runtime.replaceBackend(replacement, afterReplacement); + } catch (Throwable failure) { + replacementFailure = failure; + } finally { + synchronized (UserDataManager.this) { + if (sharedRuntimeReplacement == completion) sharedRuntimeReplacement = null; + sharedRuntimeReplacing = false; + } + } + if (replacementFailure == null) completion.complete(null); + else completion.completeExceptionally(replacementFailure); + }); + } catch (RuntimeException | Error failure) { + completion.completeExceptionally(failure); + synchronized (this) { + if (sharedRuntimeReplacement == completion) sharedRuntimeReplacement = null; + sharedRuntimeReplacing = false; + } + } + return completion; + } + + /** + * A native storage replacement must not race the shared runtime's final flush + * and owner cleanup. This remains true while asynchronous retirement is in + * progress even though the runtime is no longer available for new work. + */ + public final synchronized boolean hasSharedRuntimeLifecycle() { + return sharedRuntimeRetiring || hasSharedRuntime(); + } + + /** + * Start shared cache retirement on the manager worker. The callback runs only + * after a successful flush/retirement, so the native owner never closes a + * database while a failed shared write remains queued for recovery. + */ + public final boolean closeSharedRuntimeAsync(Runnable afterRetirement) { + return closeSharedRuntimeAsyncCompletion(afterRetirement) != null; + } + + /** + * Start shared runtime retirement and expose its completion to the platform + * lifecycle. The database work remains on this manager's worker; callers must + * not replace or close its native owner before this stage completes. + * + * @return the active retirement stage, or {@code null} when no shared runtime + * owns storage + */ + public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable afterRetirement) { + Objects.requireNonNull(afterRetirement, "afterRetirement"); + SharedUserDataRuntime runtime; + CompletableFuture completion; + synchronized (this) { + if (sharedRuntimeReplacing) { + CompletionStage replacement = sharedRuntimeReplacement; + // A failed replacement leaves the old runtime and native owner alive. + // Shutdown must still retire that old owner; thenCompose would skip the + // recursive retirement entirely on the exceptional path. + return replacement.handle((ignored, replacementFailure) -> replacementFailure) + .thenCompose(replacementFailure -> continueRetirementAfterReplacement(afterRetirement, + replacementFailure)); + } + runtime = sharedRuntime; + // 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; + sharedRuntime = null; + sharedRuntimeRetiring = true; + completion = new CompletableFuture<>(); + sharedRuntimeRetirement = completion; + } + runtime.closeAsync(timer).whenComplete((ignored, failure) -> { + if (failure != null) { + synchronized (this) { + if (sharedRuntime == null) sharedRuntime = runtime; + sharedRuntimeRetiring = false; + } + reportDeferredStorageFailure(failure); + completion.completeExceptionally(failure); + return; + } + Throwable cleanupFailure = null; + try { afterRetirement.run(); } + catch (RuntimeException | Error failureAfterRetirement) { + cleanupFailure = failureAfterRetirement; + reportDeferredStorageFailure(cleanupFailure); + } finally { synchronized (this) { sharedRuntimeRetiring = false; } } + if (cleanupFailure == null) completion.complete(null); + else completion.completeExceptionally(cleanupFailure); + }); + return completion; + } + + private CompletionStage continueRetirementAfterReplacement(Runnable afterRetirement, + Throwable replacementFailure) { + if (replacementFailure != null) reportDeferredStorageFailure(replacementFailure); + CompletionStage retirement = closeSharedRuntimeAsyncCompletion(afterRetirement); + if (retirement == null) { + if (replacementFailure == null) return CompletableFuture.completedFuture(null); + return CompletableFuture.failedFuture(replacementFailure); + } + return retirement.handle((ignored, retirementFailure) -> { + if (replacementFailure != null) { + if (retirementFailure != null) replacementFailure.addSuppressed(retirementFailure); + throw new java.util.concurrent.CompletionException(replacementFailure); + } + if (retirementFailure != null) throw new java.util.concurrent.CompletionException(retirementFailure); + return null; + }); + } + + /** + * Route one complete legacy SQL operation through lifecycle admission. The gate + * is captured only to enter the current runtime; the backend is resolved after + * admission so a concurrent replacement cannot leave this call using the closed + * provider that was current before it waited. + */ + public final T withSharedSqlBackend(UUID uuid, + BiFunction operation) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(operation, "operation"); + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Shared user storage must run on a worker thread"); + } + AtomicReference result = new AtomicReference<>(); + admission.gate().accept(uuid, () -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (current.gate() != admission.gate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for admission"); + } + SqlUserBackend selected = current.backend(); + if (!selected.isOpen()) throw new IllegalStateException("Shared SQL backend is unavailable"); + result.set(operation.apply(selected.storageType(), selected.user(uuid))); + }); + return result.get(); + } + + /** Run cache-facing work only while the requested physical store is still current. */ + public final void withSharedSqlStorage(UUID uuid, UserStorage expectedStorage, Runnable operation) { + Objects.requireNonNull(expectedStorage, "expectedStorage"); + Objects.requireNonNull(operation, "operation"); + withSharedSqlBackend(uuid, (currentStorage, ignored) -> { + if (currentStorage != expectedStorage) { + throw new IllegalStateException("Cannot access " + expectedStorage + + " user storage while the shared runtime owns " + currentStorage); + } + operation.run(); + return null; + }); + } + + /** + * Execute one complete legacy cache transition with exclusive per-user lifecycle + * admission. This prevents a writer that observed the old map entry from + * queuing work between its flush and removal. + */ + private void withSharedSqlBackendExclusive(UUID uuid, Runnable operation) { + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null) { + operation.run(); + return; + } + admission.exclusiveGate().accept(uuid, () -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (current.exclusiveGate() != admission.exclusiveGate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for exclusive admission"); + } + operation.run(); + }); + } + + /** Admit cache population through the same route that guards backend replacement and close. */ + private void withSharedCacheAdmission(UUID uuid, Runnable operation) { + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null) { + beginLegacyCacheBatch(); + try { operation.run(); } + finally { endLegacyCacheBatch(); } + return; + } + admission.gate().accept(uuid, () -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (current.gate() != admission.gate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for cache admission"); + } + operation.run(); + }); + } - @Getter - private ScheduledExecutorService timer; + /** + * Atomically chooses the shared route or admits one complete legacy provider + * call. A binding transition cannot publish a replacement while the legacy + * call is active. + */ + public final T withSharedSqlBackendOrLegacy(UUID uuid, + BiFunction sharedOperation, Supplier legacyOperation) { + Objects.requireNonNull(uuid, "uuid"); + return withSharedSqlBackendOrLegacy(() -> uuid, sharedOperation, legacyOperation); + } - @Getter - private ConcurrentHashMap userDataCache; + /** + * Supplier overload keeps legacy string identifiers opaque until a shared route + * is actually selected. + */ + public final T withSharedSqlBackendOrLegacy(Supplier uuid, + BiFunction sharedOperation, Supplier legacyOperation) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(sharedOperation, "sharedOperation"); + Objects.requireNonNull(legacyOperation, "legacyOperation"); + boolean legacy; + synchronized (sharedBindingAdmission) { + legacy = sharedSqlRoute == null; + if (legacy) { + if (sharedBindingTransition) { + throw new IllegalStateException("Legacy user storage is retired by the shared runtime"); + } + legacyBatches++; + } + } + if (!legacy) return withSharedSqlBackend(Objects.requireNonNull(uuid.get(), "uuid"), sharedOperation); + try { + return legacyOperation.get(); + } finally { + endLegacyCacheBatch(); + } + } public UserDataManager(AdvancedCorePlugin plugin) { this.plugin = plugin; @@ -48,133 +603,411 @@ public UserDataManager(AdvancedCorePlugin plugin) { booleanColumns = new ArrayList<>(); timer = Executors.newScheduledThreadPool(1); loadKeys(); - - // run every hour to clear some cache - timer.scheduleAtFixedRate(new Runnable() { - - @Override - public void run() { - if (plugin != null && plugin.isEnabled()) { - clearNonNeededCachedUsers(); - } - } + timer.scheduleAtFixedRate(() -> { + if (plugin != null && plugin.isEnabled()) clearNonNeededCachedUsers(); }, 60 * 3, 60 * 60, TimeUnit.SECONDS); } public void addKey(UserDataKey userDataKey) { keys.add(userDataKey); - if (userDataKey instanceof UserDataKeyInt) { - intColumns.add(userDataKey.getKey()); - } else if (userDataKey instanceof UserDataKeyBoolean) { - booleanColumns.add(userDataKey.getKey()); - } - + if (userDataKey instanceof UserDataKeyInt) intColumns.add(userDataKey.getKey()); + else if (userDataKey instanceof UserDataKeyBoolean) booleanColumns.add(userDataKey.getKey()); } @Deprecated public void cacheUser(UUID uuid) { - plugin.devDebug("Caching " + uuid.toString()); - if (plugin.getOptions().getDebug().isDebug(DebugLevel.DEV)) { - try { - throw new Exception("caching here: " + uuid.toString()); - } catch (Exception e) { - e.printStackTrace(); + cacheUser(uuid, true); + } + + private void cacheUser(UUID uuid, boolean traceDevelopmentCall) { + if (deferSharedCachePopulation(uuid, traceDevelopmentCall)) return; + cacheUserSynchronously(uuid, traceDevelopmentCall); + } + + private void cacheUserSynchronously(UUID uuid, boolean traceDevelopmentCall) { + cacheUserSynchronously(uuid, traceDevelopmentCall, true); + } + + private void cacheUserSynchronously(UUID uuid, boolean traceDevelopmentCall, boolean retryAfterRetirement) { + SharedCachePopulation population = hasSharedSqlBackend() ? beginSharedCachePopulation(uuid, false) : null; + CacheRefresh refreshed = new CacheRefresh(uuid); + RuntimeException runtimeFailure = null; + Error errorFailure = null; + boolean[] populated = { false }; + cacheMapLifecycle.readLock().lock(); + try { + withSharedCacheAdmission(uuid, () -> { + if (population == null || isCurrentSharedCachePopulation(population)) { + cacheUserNow(uuid, traceDevelopmentCall, refreshed); + populated[0] = true; + } + }); + } + catch (RuntimeException failure) { runtimeFailure = failure; } + catch (Error failure) { errorFailure = failure; } + finally { cacheMapLifecycle.readLock().unlock(); } + boolean current = population == null || finishSharedCachePopulation(population, runtimeFailure == null && errorFailure == null); + if (runtimeFailure != null) { + if (current) notifyCacheChangesAfterFailure(refreshed, runtimeFailure); + throw runtimeFailure; + } + if (errorFailure != null) { + if (current) notifyCacheChangesAfterFailure(refreshed, errorFailure); + throw errorFailure; + } + if (!current || !populated[0]) { + // A synchronous caller whose read was fenced before it began still expects + // one attempt in the newly published generation. Do not retry a read that + // already populated and was then retired by backend replacement: that would + // republish a cache after the replacement's clear phase completed. + if (retryAfterRetirement && population != null && !populated[0]) { + cacheUserSynchronously(uuid, traceDevelopmentCall, false); } + return; + } + notifyCacheChanges(refreshed); + } + + private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall, CacheRefresh refresh) { + plugin.devDebug("Caching " + uuid.toString()); + if (traceDevelopmentCall && plugin.getOptions().getDebug().isDebug(DebugLevel.DEV)) { + try { throw new Exception("caching here: " + uuid.toString()); } + catch (Exception e) { e.printStackTrace(); } } if (userDataCache.containsKey(uuid)) { UserDataCache data = userDataCache.get(uuid); - data.clearChanges(); - data.cache(); + refresh.flushNotification = data.clearChangesForRefresh(); + refresh.changed = data.refreshInternal(false); } else { - UserDataCache data = new UserDataCache(this, uuid).cache(); - if (data.hasCache()) { - userDataCache.put(uuid, data); - } + UserDataCache data = new UserDataCache(this, uuid); + refresh.changed = data.refreshInternal(false); + if (data.hasCache()) userDataCache.put(uuid, data); } - } public void cacheUser(UUID uuid, String playerName) { - if (playerName != null && !playerName.isEmpty()) { - if (!plugin.getOptions().isOnlineMode()) { - uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); - } + if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { + uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } - plugin.devDebug("Caching " + uuid.toString()); - if (userDataCache.containsKey(uuid)) { - UserDataCache data = userDataCache.get(uuid); - data.clearChanges(); - data.cache(); - } else { - UserDataCache data = new UserDataCache(this, uuid).cache(); - if (data.hasCache()) { - userDataCache.put(uuid, data); - } + cacheUser(uuid, false); + } + + /** + * Compatibility path for synchronous Bukkit APIs: cache population still + * happens, but its SQL read and lifecycle admission run on the manager worker. + */ + private boolean deferSharedCachePopulation(UUID uuid, boolean traceDevelopmentCall) { + if (!hasSharedSqlBackend() || Bukkit.getServer() == null || !Bukkit.isPrimaryThread()) return false; + ensureSharedCachePlaceholder(uuid); + SharedCachePopulation population = beginSharedCachePopulation(uuid, true); + if (population == null) return true; + try { + timer.execute(() -> { + CacheRefresh refreshed = new CacheRefresh(uuid); + Throwable failure = null; + boolean[] populated = { false }; + cacheMapLifecycle.readLock().lock(); + try { + withSharedCacheAdmission(uuid, () -> { + if (isCurrentSharedCachePopulation(population)) { + cacheUserNow(uuid, traceDevelopmentCall, refreshed); + populated[0] = true; + } + }); + } + catch (RuntimeException | Error caught) { failure = caught; } + finally { cacheMapLifecycle.readLock().unlock(); } + Throwable deferredFailure = failure; + boolean current = finishSharedCachePopulation(population, deferredFailure == null); + if (!current) { + if (deferredFailure != null) reportDeferredStorageFailure(deferredFailure); + return; + } + if (deferredFailure != null) { + reportDeferredStorageFailure(deferredFailure); + dispatchSharedStorageNotification(() -> { + try { notifyCacheChangesAfterFailure(refreshed, deferredFailure); } + catch (RuntimeException | Error notificationFailure) { + deferredFailure.addSuppressed(notificationFailure); + reportDeferredStorageFailure(deferredFailure); + } + }); + return; + } + if (!populated[0]) return; + dispatchSharedStorageNotification(() -> { + try { notifyCacheChanges(refreshed); } + catch (RuntimeException | Error notificationFailure) { + reportDeferredStorageFailure(notificationFailure); + } + }); + }); + } catch (RejectedExecutionException rejected) { + finishSharedCachePopulation(population, false); + reportDeferredStorageFailure(rejected); + throw rejected; } + return true; } - public void cacheUserIfNeeded(UUID uuid) { - if (!userDataCache.containsKey(uuid)) { - cacheUser(uuid); + private SharedCachePopulation beginSharedCachePopulation(UUID uuid, boolean deferred) { + SharedCachePopulationState state = sharedCachePopulationStates.computeIfAbsent(uuid, + ignored -> new SharedCachePopulationState()); + synchronized (state) { + if (deferred && !sharedCachePopulations.add(uuid)) return null; + state.activePopulations++; + return new SharedCachePopulation(uuid, state.generation, deferred); } } - public void clearCache() { - plugin.debug("Clearing cache: " + userDataCache.keySet().size()); - for (UserDataCache c : userDataCache.values()) { - c.clearCache(); - c.dump(); + private boolean isCurrentSharedCachePopulation(SharedCachePopulation population) { + SharedCachePopulationState state = sharedCachePopulationStates.get(population.uuid()); + if (state == null) return false; + synchronized (state) { return state.generation == population.generation(); } + } + + /** + * Complete a cache population only if it still belongs to the current cache + * generation. Retirement clears both externally visible marker sets before it + * advances that generation, so a queued old read cannot republish completion. + */ + private boolean finishSharedCachePopulation(SharedCachePopulation population, boolean success) { + SharedCachePopulationState state = sharedCachePopulationStates.get(population.uuid()); + if (state == null) return false; + boolean current; + synchronized (state) { + current = state.generation == population.generation(); + if (population.deferred() && current) sharedCachePopulations.remove(population.uuid()); + if (current) { + if (success) completedSharedCachePopulations.add(population.uuid()); + else completedSharedCachePopulations.remove(population.uuid()); + } + state.activePopulations--; + if (state.activePopulations == 0 && !completedSharedCachePopulations.contains(population.uuid())) { + sharedCachePopulationStates.remove(population.uuid(), state); + } } - userDataCache.clear(); + return current; + } + /** + * Retire one manager-owned cache generation. Callers that flush or replace a + * runtime must use this instead of removing the public map entry directly. + * The expected instance prevents an old owner from retiring a replacement. + */ + public final boolean retireSharedCache(UUID uuid, UserDataCache expected) { + Objects.requireNonNull(uuid, "uuid"); + SharedCachePopulationState state = sharedCachePopulationStates.computeIfAbsent(uuid, + ignored -> new SharedCachePopulationState()); + synchronized (state) { + if (userDataCache.get(uuid) != expected) return false; + if (expected != null) userDataCache.remove(uuid, expected); + sharedCachePopulations.remove(uuid); + completedSharedCachePopulations.remove(uuid); + state.generation++; + if (state.activePopulations == 0) sharedCachePopulationStates.remove(uuid, state); + return true; + } } - public void clearCacheBasic() { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().clearCacheBasic(); + /** Publish an empty cache generation without storage access for synchronous callers. */ + private UserDataCache ensureSharedCachePlaceholder(UUID uuid) { + cacheMapLifecycle.readLock().lock(); + try { return userDataCache.computeIfAbsent(uuid, ignored -> new UserDataCache(this, uuid)); } + finally { cacheMapLifecycle.readLock().unlock(); } + } + + private void notifyCacheChangesAfterFailure(CacheRefresh refresh, Throwable originalFailure) { + try { notifyCacheChanges(refresh); } + catch (RuntimeException | Error notificationFailure) { originalFailure.addSuppressed(notificationFailure); } + } + + private void notifyCacheChanges(CacheRefresh refresh) { + if (refresh == null) return; + if (refresh.flushNotification != null) refresh.flushNotification.run(); + ArrayList changed = refresh.changed; + if (!changed.isEmpty()) { + // The notification intentionally runs after lifecycle admission so listeners + // can remove or replace this user's cache. Capture the identity with the + // refresh result; the old cache may already have been retired here. + AdvancedCoreUser user = plugin.getUserManager().getUser(refresh.uuid, false); + plugin.getUserManager().onChange(user, ArrayUtils.convert(changed)); } } - public void clearNonNeededCachedUsers() { - plugin.devDebug("Clearing cache for non online players (if any)"); - ArrayList onlineUUIDS = new ArrayList<>(); - for (Player p : Bukkit.getOnlinePlayers()) { - onlineUUIDS.add(p.getUniqueId()); + private static final class CacheRefresh { + private final UUID uuid; + private ArrayList changed = new ArrayList<>(); + private Runnable flushNotification; + private CacheRefresh(UUID uuid) { this.uuid = uuid; } + } + + public void cacheUserIfNeeded(UUID uuid) { if (!userDataCache.containsKey(uuid)) cacheUser(uuid); } + + /** + * Shared SQL writes are worker-only. Existing synchronous Bukkit clear/remove + * entry points hand the complete flush-and-remove sequence to the cache worker. + */ + public final boolean deferSharedStorageWork(Runnable task) { + Objects.requireNonNull(task, "task"); + if (!hasSharedSqlBackend() || Bukkit.getServer() == null || !Bukkit.isPrimaryThread()) return false; + try { + timer.execute(() -> { + lastDeferredStorageFailure.set(null); + try { + task.run(); + } catch (RuntimeException | Error failure) { + reportDeferredStorageFailure(failure); + throw failure; + } + }); } - int removed = 0; - for (UUID uuid : userDataCache.keySet()) { - if (!onlineUUIDS.contains(uuid)) { - removeCache(uuid, null); - removed++; - } + catch (RejectedExecutionException rejected) { + reportDeferredStorageFailure(rejected); + throw rejected; } - if (removed > 0) { - plugin.devDebug("Removed " + removed + " cached users who are no longer online"); + return true; + } + + /** + * Move a legacy synchronous SQL read off the Bukkit thread and return its + * result on the platform thread. The boolean tells callers whether their + * synchronous path was deferred; a value-returning API must never substitute + * an empty or stale value while its shared-store read is pending. + */ + public final boolean deferSharedStorageResult(Supplier storageWork, Consumer success, + Consumer failure) { + return deferSharedStorageResult(storageWork, success, failure, null); + } + + /** Return player-facing completions through that entity's owning scheduler. */ + public final boolean deferSharedStorageResult(Supplier storageWork, Consumer success, + Consumer failure, org.bukkit.entity.Entity callbackOwner) { + Objects.requireNonNull(storageWork, "storageWork"); + Objects.requireNonNull(success, "success"); + Objects.requireNonNull(failure, "failure"); + if (!mustDeferSharedStorageAccess()) return false; + try { + timer.execute(() -> { + T result = null; + Throwable problem = null; + try { result = storageWork.get(); } + catch (RuntimeException | Error caught) { + problem = caught; + reportDeferredStorageFailure(caught); + } + T completed = result; + Throwable completedFailure = problem; + dispatchSharedStorageNotification(() -> { + if (completedFailure == null) success.accept(completed); + else failure.accept(completedFailure); + }, callbackOwner); + }); + } catch (RejectedExecutionException rejected) { + reportDeferredStorageFailure(rejected); + throw rejected; } + return true; } - public boolean containsKey(UUID fromString) { - return userDataCache.containsKey(fromString); + /** Return a deferred storage completion to Bukkit/Folia's safe scheduler. */ + public final void dispatchSharedStorageNotification(Runnable notification) { + dispatchSharedStorageNotification(notification, null); } - public UserDataCache getCache(UUID uuid) { - cacheUserIfNeeded(uuid); - return userDataCache.get(uuid); + private void dispatchSharedStorageNotification(Runnable notification, org.bukkit.entity.Entity callbackOwner) { + Objects.requireNonNull(notification, "notification"); + if (Bukkit.getServer() == null || Bukkit.isPrimaryThread()) notification.run(); + else if (callbackOwner != null) plugin.getBukkitScheduler().runTask(plugin, notification, callbackOwner); + else plugin.getBukkitScheduler().runTask(plugin, notification); } - public boolean isBoolean(String str) { - return booleanColumns.contains(str); + private void reportDeferredStorageFailure(Throwable failure) { + lastDeferredStorageFailure.set(failure); + if (plugin != null && plugin.getLogger() != null) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, "Deferred user-cache cleanup failed", failure); + } } - public boolean isCached(UUID uuid) { - if (userDataCache.containsKey(uuid)) { - return userDataCache.get(uuid).hasCache(); + /** Last asynchronous cache-cleanup failure, retained for diagnosis and recovery. */ + public Throwable getLastDeferredStorageFailure() { return lastDeferredStorageFailure.get(); } + + public void clearCache() { + if (deferSharedStorageWork(this::clearCacheNow)) return; + clearCacheNow(); + } + + private void clearCacheNow() { + // Do not retain this map lock while flushing through a shared runtime gate. + // A population already admitted by that runtime needs the map read lock to + // publish, while a queued lifecycle writer would otherwise block this flush. + // Mark the current generation as retiring under the map lock, then perform + // its storage work without that lock and finally detach only those instances. + java.util.HashMap retiring = new java.util.HashMap<>(); + cacheMapLifecycle.writeLock().lock(); + try { + plugin.debug("Clearing cache: " + userDataCache.keySet().size()); + retiring.putAll(userDataCache); + for (UserDataCache cache : retiring.values()) cache.beginRemoval(); + } finally { cacheMapLifecycle.writeLock().unlock(); } + try { + for (Entry entry : retiring.entrySet()) { + UUID uuid = entry.getKey(); + UserDataCache cache = entry.getValue(); + // The map write lock only protects publication. Retiring each cache + // must also exclude the normal per-user read admission used by + // addChange(), otherwise a queued setter can observe this mapped cache + // after beginRemoval() and have its mutation silently discarded. + withSharedSqlBackendExclusive(uuid, () -> clearCacheExclusively(uuid, cache)); + } + } catch (RuntimeException | Error failure) { + // All not-yet-detached caches were marked before flushing began. Reopen + // each surviving cache so one user's failed write cannot discard later + // updates for another cache that has not been flushed yet. + for (UserDataCache cache : retiring.values()) cache.cancelRemoval(); + throw failure; } - return false; } - public boolean isInt(String str) { - return intColumns.contains(str); + private void clearCacheExclusively(UUID uuid, UserDataCache cache) { + cache.clearCache(); + cache.dump(); + boolean removed = retireSharedCache(uuid, cache); + Consumer listener = sharedCacheRemovalListener; + if (removed && listener != null) listener.accept(uuid); + } + + public void clearCacheBasic() { + if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic(); + } + + public void clearNonNeededCachedUsers() { + plugin.devDebug("Clearing cache for non online players (if any)"); + ArrayList onlineUUIDS = new ArrayList<>(); + for (Player p : Bukkit.getOnlinePlayers()) onlineUUIDS.add(p.getUniqueId()); + int removed = 0; + for (UUID uuid : userDataCache.keySet()) { + if (!onlineUUIDS.contains(uuid)) { removeCache(uuid, null); removed++; } + } + if (removed > 0) plugin.devDebug("Removed " + removed + " cached users who are no longer online"); + } + + public boolean containsKey(UUID fromString) { return userDataCache.containsKey(fromString); } + public UserDataCache getCache(UUID uuid) { + if (hasSharedSqlBackend() && Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + UserDataCache cache = userDataCache.get(uuid); + if (cache == null) cache = ensureSharedCachePlaceholder(uuid); + if (!completedSharedCachePopulations.contains(uuid)) cacheUser(uuid, false); + return cache; + } + cacheUserIfNeeded(uuid); + return userDataCache.get(uuid); + } + public boolean isBoolean(String str) { return booleanColumns.contains(str); } + public boolean isCached(UUID uuid) { return userDataCache.containsKey(uuid) && userDataCache.get(uuid).hasCache(); } + public boolean isInt(String str) { return intColumns.contains(str); } + public boolean mustDeferSharedStorageAccess() { + return hasSharedSqlBackend() && Bukkit.getServer() != null && Bukkit.isPrimaryThread(); } private void loadKeys() { @@ -190,23 +1023,39 @@ private void loadKeys() { } public void removeCache(UUID uuid, String playerName) { - if (playerName != null && !playerName.isEmpty()) { - if (!plugin.getOptions().isOnlineMode()) { - uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); - } + UUID resolved = uuid; + if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { + resolved = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } + UUID target = resolved; + if (deferSharedStorageWork(() -> removeCacheNow(target))) return; + removeCacheNow(target); + } + + private void removeCacheNow(UUID uuid) { + withSharedSqlBackendExclusive(uuid, () -> removeCacheExclusively(uuid, sharedSqlRoute != null)); + } + + private void removeCacheExclusively(UUID uuid, boolean shared) { UserDataCache cache = getCache(uuid); if (cache != null) { - cache.clearCache(); + cache.beginRemoval(); + try { + cache.clearCache(); + if (shared) cache.retireAfterSharedFlush(); + } catch (RuntimeException | Error failure) { + // The failed cache is still mapped with its queued write retained. It + // must resume accepting mutations so a later cleanup can retry safely. + cache.cancelRemoval(); + throw failure; + } } - userDataCache.remove(uuid); + boolean removed = retireSharedCache(uuid, cache); + Consumer listener = sharedCacheRemovalListener; + if (removed && listener != null) listener.accept(uuid); } public void updateCacheOnline() { - for (Player p : Bukkit.getOnlinePlayers()) { - if (isCached(p.getUniqueId())) { - cacheUser(p.getUniqueId()); - } - } + for (Player p : Bukkit.getOnlinePlayers()) if (isCached(p.getUniqueId())) cacheUser(p.getUniqueId()); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java index 6aa095d355..22e1349c5f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/mysql/MySQL.java @@ -186,6 +186,16 @@ public boolean containsUUID(String uuid) { */ public void forEachUser(java.util.function.BiConsumer> perUser, java.util.function.Consumer onFinished) { + forEachUser(perUser, onFinished, failure -> { + throw new IllegalStateException("Failed to enumerate MySQL users", failure); + }); + } + + /** Streams users and reports a terminal read/callback failure without claiming completion. */ + public void forEachUser(java.util.function.BiConsumer> perUser, + java.util.function.Consumer onFinished, java.util.function.Consumer onFailure) { + java.util.Objects.requireNonNull(perUser, "perUser"); + java.util.Objects.requireNonNull(onFailure, "onFailure"); int processed = 0; final int pageSize = 500; @@ -234,6 +244,7 @@ public void forEachUser(java.util.function.BiConsumer> p final int colCount = meta.getColumnCount(); while (rs.next()) { + rowsThisPage++; ArrayList cols = new ArrayList<>(colCount); UUID uuid = null; @@ -298,39 +309,46 @@ public void forEachUser(java.util.function.BiConsumer> p cols.add(rCol); } - if (uuid != null && uuidStrForSeek != null) { - rowsThisPage++; - processed++; - - // advance cursor based on the last row - if (dbType == DbType.POSTGRESQL) { - lastUuidPg = uuid; - } else { - lastUuidMy = uuidStrForSeek; - } - + if (uuidStrForSeek != null) { + if (dbType == DbType.POSTGRESQL) { + if (uuid != null) lastUuidPg = uuid; + } else { + lastUuidMy = uuidStrForSeek; + } + } + + if (uuid != null && uuidStrForSeek != null) { + processed++; page.add(new java.util.AbstractMap.SimpleEntry<>(uuid, cols)); } } } - } catch (SQLException e) { - debug(e); - break; + } catch (SQLException | RuntimeException e) { + debug(e); + onFailure.accept(e); + return; } // Run callbacks outside DB resources for (java.util.AbstractMap.SimpleEntry> entry : page) { try { perUser.accept(entry.getKey(), entry.getValue()); - } catch (Throwable t) { - debug(t); + } catch (Throwable t) { + debug(t); + onFailure.accept(t); + return; } } - if (rowsThisPage == 0) { - break; - } + if (rowsThisPage < pageSize) { + break; + } + if ((dbType == DbType.POSTGRESQL && lastUuidPg == null) + || (dbType != DbType.POSTGRESQL && lastUuidMy == null)) { + onFailure.accept(new IllegalStateException("User enumeration page did not advance its UUID cursor")); + return; + } } if (onFinished != null) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/sql/UserTable.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/sql/UserTable.java index 9b9b7e801d..66e298a45e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/sql/UserTable.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/userstorage/sql/UserTable.java @@ -61,122 +61,102 @@ public UserTable(AdvancedCorePlugin plugin, String name, Collection colu * @param onFinished called once at the end with the number of rows processed */ public void forEachUser(BiConsumer> perUser, Consumer onFinished) { - int processed = 0; - final String query = "SELECT * FROM " + getName() + ";"; - - try (PreparedStatement ps = sqLite.getSQLConnection().prepareStatement(query, ResultSet.TYPE_FORWARD_ONLY, - ResultSet.CONCUR_READ_ONLY); ResultSet rs = ps.executeQuery()) { - - // Helps on some JDBC drivers (SQLite varies, but harmless) - try { - ps.setFetchSize(500); - } catch (Exception ignored) { - } - - // Compute metadata ONCE - final ResultSetMetaData meta = rs.getMetaData(); - final int colCount = meta.getColumnCount(); - - // Precompute how each column should be read - final String[] colNames = new String[colCount + 1]; - final byte[] kind = new byte[colCount + 1]; // 0=string, 1=int, 2=bool - int uuidIndex = -1; - - for (int i = 1; i <= colCount; i++) { - String name = meta.getColumnLabel(i); - colNames[i] = name; - - if ("uuid".equalsIgnoreCase(name)) { - uuidIndex = i; - } - - if (plugin.getUserManager().getDataManager().isInt(name)) { - kind[i] = 1; - } else if (plugin.getUserManager().getDataManager().isBoolean(name)) { - kind[i] = 2; - } else { - kind[i] = 0; - } - } - - while (rs.next()) { - UUID uuid = null; + forEachUser(perUser, onFinished, failure -> { + throw new IllegalStateException("Failed to enumerate SQLite users", failure); + }); + } - // Fast path: read uuid column directly (avoid scanning for it) - if (uuidIndex > 0) { - String uuidStr = rs.getString(uuidIndex); - if (uuidStr != null && !uuidStr.isEmpty() && !"null".equalsIgnoreCase(uuidStr)) { - try { - uuid = UUID.fromString(uuidStr); - } catch (IllegalArgumentException ignored) { - // bad uuid; skip row below - } + /** Streams users and reports a terminal SQL failure without claiming completion. */ + public void forEachUser(BiConsumer> perUser, Consumer onFinished, + Consumer onFailure) { + java.util.Objects.requireNonNull(perUser, "perUser"); + java.util.Objects.requireNonNull(onFailure, "onFailure"); + int processed = 0; + final int pageSize = 500; + String cursor = null; + while (true) { + ArrayList page = new ArrayList<>(pageSize); + int rowsThisPage = 0; + String nextCursor = cursor; + String query = "SELECT * FROM " + getName() + " WHERE uuid IS NOT NULL" + + (cursor == null ? "" : " AND uuid > ?") + " ORDER BY uuid ASC LIMIT ?;"; + try (PreparedStatement ps = sqLite.getSQLConnection().prepareStatement(query, ResultSet.TYPE_FORWARD_ONLY, + ResultSet.CONCUR_READ_ONLY)) { + int parameter = 1; + if (cursor != null) ps.setString(parameter++, cursor); + ps.setInt(parameter, pageSize); + try (ResultSet rs = ps.executeQuery()) { + ResultSetMetaData meta = rs.getMetaData(); + int colCount = meta.getColumnCount(); + String[] colNames = new String[colCount + 1]; + byte[] kind = new byte[colCount + 1]; + int uuidIndex = -1; + for (int i = 1; i <= colCount; i++) { + String name = meta.getColumnLabel(i); + colNames[i] = name; + if ("uuid".equalsIgnoreCase(name)) uuidIndex = i; + if (plugin.getUserManager().getDataManager().isInt(name)) kind[i] = 1; + else if (plugin.getUserManager().getDataManager().isBoolean(name)) kind[i] = 2; } - } - - // Build cols only if we have a valid uuid (saves a ton if db has junk) - if (uuid == null) { - continue; - } - - ArrayList cols = new ArrayList<>(colCount); - - for (int i = 1; i <= colCount; i++) { - String columnName = colNames[i]; - Column rCol; - - switch (kind[i]) { - case 1: { // int - rCol = new Column(columnName, DataType.INTEGER); - int v; - try { - v = rs.getInt(i); - if (rs.wasNull()) - v = 0; - } catch (Exception e) { - String data = rs.getString(i); - if (data != null) { - try { - v = Integer.parseInt(data); - } catch (NumberFormatException ex) { - v = 0; + if (uuidIndex < 1) throw new IllegalStateException("SQLite user table has no UUID column"); + while (rs.next()) { + rowsThisPage++; + String uuidString = rs.getString(uuidIndex); + nextCursor = uuidString; + UUID uuid = null; + if (uuidString != null && !uuidString.isEmpty() && !"null".equalsIgnoreCase(uuidString)) { + try { uuid = UUID.fromString(uuidString); } + catch (IllegalArgumentException ignored) { } + } + if (uuid == null) continue; + ArrayList columns = new ArrayList<>(colCount); + for (int i = 1; i <= colCount; i++) { + String columnName = colNames[i]; + Column column; + if (kind[i] == 1) { + column = new Column(columnName, DataType.INTEGER); + int value; + try { value = rs.getInt(i); if (rs.wasNull()) value = 0; } + catch (Exception failure) { + String data = rs.getString(i); + try { value = data == null ? 0 : Integer.parseInt(data); } + catch (NumberFormatException invalid) { value = 0; } } + column.setValue(new DataValueInt(value)); + } else if (kind[i] == 2) { + column = new Column(columnName, DataType.BOOLEAN); + column.setValue(new DataValueBoolean(Boolean.valueOf(rs.getString(i)))); } else { - v = 0; + column = new Column(columnName, DataType.STRING); + column.setValue(new DataValueString(rs.getString(i))); } + columns.add(column); } - rCol.setValue(new DataValueInt(v)); - break; - } - case 2: { // bool - rCol = new Column(columnName, DataType.BOOLEAN); - // Keep same semantics as your original: Boolean.valueOf(String) - rCol.setValue(new DataValueBoolean(Boolean.valueOf(rs.getString(i)))); - break; + page.add(new EnumerationEntry(uuid, columns)); } - default: { // string - rCol = new Column(columnName, DataType.STRING); - rCol.setValue(new DataValueString(rs.getString(i))); - break; - } - } - - cols.add(rCol); } - - processed++; - perUser.accept(uuid, cols); + } catch (SQLException | RuntimeException failure) { + onFailure.accept(failure); + return; } - - } catch (SQLException e) { - e.printStackTrace(); - } finally { - if (onFinished != null) { - onFinished.accept(processed); + for (EnumerationEntry entry : page) { + try { perUser.accept(entry.uuid(), entry.columns()); processed++; } + catch (Throwable failure) { onFailure.accept(failure); return; } } + if (rowsThisPage < pageSize) break; + if (nextCursor == null || java.util.Objects.equals(cursor, nextCursor)) { + onFailure.accept(new IllegalStateException("SQLite user enumeration did not advance its UUID cursor")); + return; + } + cursor = nextCursor; + } + if (onFinished != null) { + onFinished.accept(processed); } } + private record EnumerationEntry(UUID uuid, ArrayList columns) { } + public UserTable(AdvancedCorePlugin plugin, String name, Column... columns) { this.name = name; for (Column column : columns) { 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 c5b160cb61..3233018bda 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 @@ -2,18 +2,24 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; +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.time.TimeChecker; +import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.core.platform.RuntimePlatform; /** Bukkit services for the shared executor lifecycle; no duplicate owners are created. */ public final class BukkitRuntimePlatform implements RuntimePlatform { private final AdvancedCorePlugin plugin; + private volatile CompletionStage userStorageRetirement = CompletableFuture.completedFuture(null); public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -39,11 +45,17 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { JavascriptEngineHandler.getInstance().clearCachedEngine(); } }), + new Cleanup("user storage", this::closeUserStorageAfterSharedRetirement), new Cleanup("server data timestamp", () -> { if (plugin.getServerDataFile() != null) plugin.getServerDataFile().setLastUpdated(); })); } + @Override public CompletionStage beforeExecutorShutdownCompletion() { return userStorageRetirement; } + @Override public boolean canBlockForPreExecutorShutdown() { + return Bukkit.getServer() == null || !Bukkit.isPrimaryThread(); + } + @Override public List afterExecutorGrace() { return List.of(new Cleanup("reward handler", () -> { if (plugin.getRewardHandler() != null) plugin.getRewardHandler().shutdown(); @@ -52,17 +64,6 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { @Override public List afterExecutorShutdown() { return List.of( - new Cleanup("MySQL", () -> { - if (plugin.isLoadUserData() && plugin.getOptions() != null - && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { - ScheduledExecutorService timer = plugin.getTimer(); - if (timer != null && !timer.isTerminated()) { - plugin.getLogger().warning("Leaving MySQL open because reward checkpoint tasks did not terminate"); - return; - } - plugin.getMysql().close(); - } - }), new Cleanup("plugin unload hook", plugin::onUnLoad), new Cleanup("skull cache", () -> { if (plugin.getSkullCacheHandler() != null) plugin.getSkullCacheHandler().close(); @@ -83,4 +84,31 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { plugin.getLogger().warning("Failed to shut down " + component + ": " + failure.getMessage()); plugin.debug(failure); } + + private void closeUserStorageAfterSharedRetirement() { + if (!plugin.isLoadUserData()) { + userStorageRetirement = CompletableFuture.completedFuture(null); + return; + } + UserManager users = plugin.getLoadedUserManager(); + var mysql = plugin.getMysql(); + boolean ownsMysql; + if (users != null && users.getDataManager().hasSharedSqlBackend()) { + ownsMysql = users.getDataManager().usesSharedSqlStorage(UserStorage.MYSQL); + } else { + ownsMysql = plugin.getOptions() != null + && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()); + } + Runnable closeMysql = () -> { if (ownsMysql && mysql != null) mysql.close(); }; + if (users == null) { + closeMysql.run(); + userStorageRetirement = CompletableFuture.completedFuture(null); + return; + } + CompletionStage retirement = users.getDataManager().closeSharedRuntimeAsyncCompletion(closeMysql); + if (retirement == null) { + closeMysql.run(); + userStorageRetirement = CompletableFuture.completedFuture(null); + } else userStorageRetirement = retirement; + } } 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 new file mode 100644 index 0000000000..052c1cfe1d --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java @@ -0,0 +1,301 @@ +package com.bencodez.advancedcore.bukkit.user.runtime; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicReference; +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; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeBoolean; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString; +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; +import com.bencodez.simpleapi.sql.data.DataValue; + +/** Reuses the existing manager, cache instances, queue and notification ordering. */ +public final class BukkitUserCacheOwner implements UserCacheOwner { + private final UserDataManager manager; + private volatile SqlUserBackend backend; + private volatile Consumer flushGate; + private volatile BiConsumer userGate; + private volatile BiConsumer exclusiveUserGate; + private final ConcurrentHashMap> cacheGates = new ConcurrentHashMap<>(); + private final ConcurrentHashMap> pendingNotifications = + new ConcurrentHashMap<>(); + private final Consumer cacheRemovalListener = cacheGates::remove; + private final Consumer cacheInitializer; + + public BukkitUserCacheOwner(UserDataManager manager) { + this.manager = Objects.requireNonNull(manager, "manager"); + cacheInitializer = cache -> { + Consumer gate = cacheGate(cache.getUuid()); + if (gate == null) bind(cache, cache.getUuid()); + else gate.accept(() -> bind(cache, cache.getUuid())); + }; + } + + @Override public synchronized void bindFlushGate(Consumer gate) { + Objects.requireNonNull(gate, "gate"); + if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + flushGate = gate; + } + + @Override public synchronized void bindUserGate(BiConsumer gate) { + Objects.requireNonNull(gate, "gate"); + if (userGate != null && userGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + userGate = gate; + } + + @Override + public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer perUserGate) { + bindLifecycle(backend, gate, perUserGate, perUserGate); + } + + @Override + public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer perUserGate, BiConsumer perUserExclusiveGate) { + Objects.requireNonNull(backend, "backend"); + Objects.requireNonNull(gate, "gate"); + Objects.requireNonNull(perUserGate, "perUserGate"); + Objects.requireNonNull(perUserExclusiveGate, "perUserExclusiveGate"); + if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + if (userGate != null && userGate != perUserGate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + if (exclusiveUserGate != null && exclusiveUserGate != perUserExclusiveGate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + + manager.beginSharedBindingTransition(); + try { + manager.bindSharedCacheInitializer(cacheInitializer); + manager.bindSharedCacheRemovalListener(cacheRemovalListener); + manager.bindSharedSqlBackend(backend, gate, perUserGate, perUserExclusiveGate); + this.backend = backend; + flushGate = gate; + userGate = perUserGate; + exclusiveUserGate = perUserExclusiveGate; + cacheGates.clear(); + } finally { + manager.endSharedBindingTransition(); + } + } + + @Override public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { + BiConsumer existing = userGate; + if (existing == null) existing = (uuid, operation) -> gate.accept(operation); + BiConsumer exclusive = exclusiveUserGate; + if (exclusive == null) exclusive = existing; + bindLifecycle(backend, gate, existing, exclusive); + } + + @Override public synchronized void bindBackend(SqlUserBackend backend) { + Objects.requireNonNull(backend, "backend"); + manager.bindSharedCacheInitializer(cacheInitializer); + manager.bindSharedCacheRemovalListener(cacheRemovalListener); + this.backend = backend; + BiConsumer perUser = userGate; + if (perUser != null) manager.bindSharedSqlBackend(backend, flushGate, perUser, + exclusiveUserGate == null ? perUser : exclusiveUserGate); + else if (flushGate != null) manager.bindSharedSqlBackend(backend, flushGate); + } + + private Consumer cacheGate(UUID uuid) { + BiConsumer perUser = userGate; + if (perUser != null && uuid != null) { + return cacheGates.computeIfAbsent(uuid, id -> operation -> { + BiConsumer current = userGate; + if (current == null) throw new IllegalStateException("Shared user lifecycle is not bound"); + current.accept(id, operation); + }); + } + return flushGate; + } + + private void bind(UserDataCache cache, UUID uuid) { + if (backend == null) return; + // Do not capture the provider. Detached caches can outlive a replacement + // boundary before they are inserted into the manager map. Their writer is + // always invoked under the stable per-user lifecycle gate, then resolves + // whichever backend is current after that admission. + cache.configureSharedStorage(values -> { + requireBlockingAllowed(); + SqlUserBackend selected = backend; + if (selected == null || !selected.isOpen()) { + throw new IllegalStateException("Shared SQL backend is unavailable"); + } + selected.user(uuid).writeValues(selected.storageType(), values); + }, cacheGate(uuid)); + } + + @Override public void requireBlockingAllowed() { + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) throw new IllegalStateException("Shared user storage must run on a worker; use closeAsync for shutdown"); + } + + @Override public boolean isCached(UUID uuid) { return manager.isCached(uuid); } + + @Override public DataValue getIfPresent(UUID uuid, String key) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache == null) return null; + synchronized (cache) { return cache.getCache() == null ? null : cache.getCache().get(key); } + } + + @Override public void populate(UUID uuid, HashMap values) { + Boolean admitted = manager.withCacheMapReadAdmission(() -> { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + cache.updateCachePreservingPending(values); + return Boolean.TRUE; + }); + // Test doubles and legacy adapters may not implement the optional admission hook. + if (admitted == null) populateWithoutMapAdmission(uuid, values); + } + + private void populateWithoutMapAdmission(UUID uuid, HashMap values) { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + cache.updateCachePreservingPending(values); + } + + private record CachePopulation(UUID uuid, UserDataCache cache, long version) implements PopulationToken {} + + @Override public PopulationToken beginPopulation(UUID uuid) { + CachePopulation admitted = manager.withCacheMapReadAdmission(() -> { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); + }); + if (admitted != null) return admitted; + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); + } + + @Override public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { + if (!(token instanceof CachePopulation expected) || !uuid.equals(expected.uuid())) throw new IllegalArgumentException("Population token does not belong to this user"); + AtomicReference> populated = new AtomicReference<>(); + HashMap admitted = manager.withCacheMapReadAdmission(() -> { + manager.getUserDataCache().compute(uuid, (ignored, current) -> { + if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); + populated.set(current.updateSharedSnapshot(values, expected.version())); + return current; + }); + return populated.get(); + }); + if (admitted == null) { + manager.getUserDataCache().compute(uuid, (ignored, current) -> { + if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); + populated.set(current.updateSharedSnapshot(values, expected.version())); + return current; + }); + } + return populated.get(); + } + + @Override public void queueChange(UUID uuid, String key, DataValue value) { + Objects.requireNonNull(value, "value"); + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache == null) throw new IllegalStateException("User must be populated before queuing: " + uuid); + bind(cache, uuid); + cache.addChange(change(key, value), true); + } + + @Override public void flush(UUID uuid, SqlUserStorage storage) { + UserStorage type = backend == null ? manager.getPlugin().getStorageType() : backend.storageType(); + flush(uuid, type, storage); + } + + @Override public void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) { + bind(cache, uuid); + cache.setSharedStorageWriter(values -> { + requireBlockingAllowed(); + storage.writeValues(type, values); + }); + do { + Runnable notification = cache.processChangesForSharedRuntime(); + if (notification != null) pendingNotifications.compute(uuid, (ignored, notifications) -> { + ConcurrentLinkedQueue queue = notifications == null + ? new ConcurrentLinkedQueue<>() : notifications; + queue.add(notification); + return queue; + }); + } while (cache.hasChangesToProcess()); + } + } + + @Override public void dispatchNotifications(UUID uuid) { + ConcurrentLinkedQueue notifications = pendingNotifications.remove(uuid); + if (notifications == null) return; + // Preserve the established storage-worker callback contract. The runtime has + // released per-user admission here, so callbacks may perform exclusive storage + // work; moving them to Bukkit's primary thread would make that work illegal. + Runnable notification; + while ((notification = notifications.poll()) != null) { + try { notification.run(); } + catch (RuntimeException | Error failure) { manager.getPlugin().debug(failure); } + } + } + + @Override public void dispatchAllNotifications() { + for (UUID uuid : Set.copyOf(pendingNotifications.keySet())) dispatchNotifications(uuid); + } + + @Override public void discardAllNotifications() { pendingNotifications.clear(); } + + @Override public Set cachedUsers() { return new HashSet<>(manager.getUserDataCache().keySet()); } + + @Override public void beginRemoval(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) cache.beginRemoval(); + } + + @Override public void cancelRemoval(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) cache.cancelRemoval(); + } + + @Override public void beginRetirement() { + for (UserDataCache cache : manager.getUserDataCache().values()) cache.beginRemoval(); + } + + @Override public void cancelRetirement() { + for (UserDataCache cache : manager.getUserDataCache().values()) cache.cancelRemoval(); + } + + @Override public void remove(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) { + cache.retireAfterSharedFlush(); + } + if (manager.retireSharedCache(uuid, cache)) cacheGates.remove(uuid); + } + + @Override public void clearAfterFlush() { for (UUID uuid : cachedUsers()) remove(uuid); } + + @Override public void shutdown() { + if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { + timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); + } + manager.getTimer().shutdown(); + } + + private UserDataChange change(String key, DataValue value) { + if (value.isInt()) return new UserDataChangeInt(key, value.getInt()); + if (value.isBoolean()) return new UserDataChangeBoolean(key, value.getBoolean()); + return new UserDataChangeString(key, value.getString()); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java new file mode 100644 index 0000000000..9cb13a836c --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java @@ -0,0 +1,31 @@ +package com.bencodez.advancedcore.bukkit.user.runtime; + +import java.util.Objects; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; + +/** Creates the shared route only after Bukkit has initialized its native user storage. */ +public final class BukkitUserRuntimeBootstrap { + private BukkitUserRuntimeBootstrap() {} + + public static void bindAfterStorageInitialization(AdvancedCorePlugin plugin, UserDataManager manager) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(manager, "manager"); + if (manager.hasSharedRuntime()) return; + if (manager.hasSharedRuntimeLifecycle()) { + throw new IllegalStateException("Shared user storage retirement is still in progress"); + } + SharedUserDataRuntime runtime = new SharedUserDataRuntime(new BukkitSqlUserBackend(plugin), + new BukkitUserCacheOwner(manager)); + try { + manager.bindSharedRuntime(runtime); + } catch (RuntimeException | Error failure) { + try { runtime.closeAsync(manager.getTimer()); } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + throw failure; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java new file mode 100644 index 0000000000..1b845cad6f --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java @@ -0,0 +1,179 @@ +package com.bencodez.advancedcore.bukkit.user.storage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueString; + +/** + * Non-owning bridge to the Bukkit storage objects already initialized by the + * plugin. Closing this bridge only retires the shared route; the plugin keeps + * ownership of MySQL/UserTable resource shutdown. + */ +public final class BukkitSqlUserBackend implements SqlUserBackend { + private final AdvancedCorePlugin plugin; + private final UserStorage storageType; + private final MySQL mysql; + private final UserTable table; + private final Object sqliteOperations = new Object(); + private final AtomicBoolean open = new AtomicBoolean(true); + + public BukkitSqlUserBackend(AdvancedCorePlugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.storageType = Objects.requireNonNull(plugin.getStorageType(), "storageType"); + if (storageType() == UserStorage.MYSQL) { + this.mysql = plugin.getMysql(); + this.table = null; + if (mysql == null) throw new IllegalStateException("Bukkit MySQL user storage is unavailable"); + } else { + this.mysql = null; + this.table = plugin.getSQLiteUserTable(); + if (table == null) throw new IllegalStateException("Bukkit SQLite user storage is unavailable"); + } + } + + /** + * Bind a replacement runtime to native storage prepared off-thread. The + * backend deliberately retains these exact owners instead of resolving the + * plugin's mutable fields on each operation, so a configuration reload cannot + * redirect an old flush to a newly installed provider. + */ + public BukkitSqlUserBackend(AdvancedCorePlugin plugin, UserStorage storageType, MySQL mysql, UserTable table) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.storageType = Objects.requireNonNull(storageType, "storageType"); + this.mysql = storageType == UserStorage.MYSQL ? Objects.requireNonNull(mysql, "mysql") : null; + this.table = storageType == UserStorage.SQLITE ? Objects.requireNonNull(table, "table") : null; + } + + @Override public UserStorage storageType() { return storageType; } + + @Override + public SqlUserStorage user(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + requireOpen(); + return new SqlUserStorage() { + @Override public List readRow(UserStorage storage) { return read(storage, uuid); } + @Override public boolean contains(UserStorage storage) { return BukkitSqlUserBackend.this.contains(storage, uuid); } + @Override public void delete(UserStorage storage) { BukkitSqlUserBackend.this.delete(storage, uuid); } + @Override public void write(UserStorage storage, String key, DataValue value) { + BukkitSqlUserBackend.this.write(storage, uuid, key, value); + } + @Override public void writeValues(UserStorage storage, HashMap values) { + BukkitSqlUserBackend.this.writeValues(storage, uuid, values); + } + }; + } + + @Override + public List enumerateUsers() { + ArrayList users = new ArrayList<>(); + forEachUser(uuid -> { + if (users.size() >= MAX_MATERIALIZED_USERS) { + throw new IllegalStateException("User enumeration exceeds " + MAX_MATERIALIZED_USERS + " entries; use forEachUser"); + } + users.add(uuid); + }); + return users; + } + + @Override + public void forEachUser(Consumer consumer) { + Objects.requireNonNull(consumer, "consumer"); + requireOpen(); + UserStorage storage = storageType(); + if (storage == UserStorage.MYSQL) { + mysql().forEachUser((uuid, ignored) -> consumer.accept(uuid), ignored -> {}, + failure -> { throw enumerationFailure("MySQL", failure); }); + return; + } + synchronized (sqliteOperations) { + table().forEachUser((uuid, ignored) -> consumer.accept(uuid), ignored -> {}, + failure -> { throw enumerationFailure("SQLite", failure); }); + } + } + + private IllegalStateException enumerationFailure(String storage, Throwable failure) { + return new IllegalStateException("Failed to enumerate " + storage + " users", failure); + } + + @Override public boolean isOpen() { return open.get(); } + + /** The Bukkit plugin owns and closes the underlying connections. */ + @Override public void close() { open.set(false); } + + private List read(UserStorage storage, UUID uuid) { + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) return mysql().getExact(uuid.toString()); + synchronized (sqliteOperations) { return table().getExact(primary(uuid)); } + } + + private boolean contains(UserStorage storage, UUID uuid) { + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) return mysql().containsKey(uuid.toString()); + synchronized (sqliteOperations) { return table().containsKey(uuid.toString()); } + } + + private void delete(UserStorage storage, UUID uuid) { + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) mysql().deletePlayer(uuid.toString()); + else synchronized (sqliteOperations) { table().delete(primary(uuid)); } + } + + private void write(UserStorage storage, UUID uuid, String key, DataValue value) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) mysql().update(uuid.toString(), key, value); + else synchronized (sqliteOperations) { table().update(primary(uuid), new ArrayList<>(List.of(new Column(key, value)))); } + } + + private void writeValues(UserStorage storage, UUID uuid, HashMap values) { + Objects.requireNonNull(values, "values"); + requireOpen(); + requireStorage(storage); + ArrayList columns = new ArrayList<>(); + values.forEach((key, value) -> { + if (!"uuid".equalsIgnoreCase(key) && value != null) columns.add(new Column(key, value)); + }); + if (columns.isEmpty()) return; + if (storage == UserStorage.MYSQL) mysql().update(uuid.toString(), columns, false); + else synchronized (sqliteOperations) { table().update(primary(uuid), columns); } + } + + private Column primary(UUID uuid) { return new Column("uuid", new DataValueString(uuid.toString())); } + private MySQL mysql() { return requireMysql(); } + private UserTable table() { return requireTable(); } + + private MySQL requireMysql() { + if (mysql == null) throw new IllegalStateException("Bukkit MySQL user storage is unavailable"); + return mysql; + } + + private UserTable requireTable() { + if (table == null) throw new IllegalStateException("Bukkit SQLite user storage is unavailable"); + return table; + } + + private void requireOpen() { if (!open.get()) throw new IllegalStateException("Bukkit SQL user backend is retired"); } + private void requireStorage(UserStorage storage) { + if (storage != storageType()) throw new IllegalArgumentException( + "Storage mismatch: backend=" + storageType() + ", requested=" + storage); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java index 6ffe2b56ed..8cbf11b943 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java @@ -5,20 +5,19 @@ import java.util.List; import java.util.Map.Entry; import java.util.Objects; +import java.util.UUID; +import java.util.function.BiFunction; import java.util.function.Supplier; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueString; -/** - * Adapts the existing SQL providers. Plugin/table/UUID lookup stays live so a - * queued operation resolves the same current ownership as the legacy facade. - * Construction creates no user, cache, connection, executor, or schema. - */ +/** Adapts the existing SQL providers and the shared-runtime replacement route. */ public final class BukkitSqlUserStorage implements SqlUserStorage { private final Supplier plugin; private final Supplier uuid; @@ -28,75 +27,92 @@ public BukkitSqlUserStorage(Supplier plugin, Supplier T routed(UserStorage requestedStorage, BiFunction sharedOperation, + Supplier legacyOperation) { + UserDataManager manager = dataManager(); + // Cross-store access is valid only for the converter's runtime-exclusive + // maintenance action. Outside that narrow scope, route every operation + // through the shared backend so a request cannot silently hit another store. + return manager == null || manager.isStorageMaintenanceActive() ? legacyOperation.get() + : manager.withSharedSqlBackendOrLegacy(this::userId, (sharedStorage, target) -> { + if (sharedStorage != requestedStorage) { + throw new IllegalStateException("Cannot access " + requestedStorage + + " user storage while the shared runtime owns " + sharedStorage); + } + return sharedOperation.apply(requestedStorage, target); + }, legacyOperation); + } @Override public List readRow(UserStorage storage) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - return owner().getMysql().getExact(uuid.get()); - } - return owner().getSQLiteUserTable().getExact(primary()); + Objects.requireNonNull(storage, "storage"); + return routed(storage, (type, target) -> target.readRow(type), () -> storage == UserStorage.MYSQL + ? owner().getMysql().getExact(uuid.get()) : owner().getSQLiteUserTable().getExact(primary())); } @Override public boolean contains(UserStorage storage) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - return owner().getMysql().containsKey(uuid.get()); - } - return owner().getSQLiteUserTable().containsKey(uuid.get()); + Objects.requireNonNull(storage, "storage"); + return routed(storage, (type, target) -> target.contains(type), () -> storage == UserStorage.MYSQL + ? owner().getMysql().containsKey(uuid.get()) : owner().getSQLiteUserTable().containsKey(uuid.get())); } @Override public void delete(UserStorage storage) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - owner().getMysql().deletePlayer(uuid.get()); - } else { - owner().getSQLiteUserTable().delete(primary()); - } + Objects.requireNonNull(storage, "storage"); + routed(storage, (type, target) -> { target.delete(type); return null; }, () -> { + if (storage == UserStorage.MYSQL) owner().getMysql().deletePlayer(uuid.get()); + else owner().getSQLiteUserTable().delete(primary()); + return null; + }); } @Override public void write(UserStorage storage, String key, DataValue value) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.SQLITE) { - ArrayList columns = new ArrayList<>(); - Column primary = primary(); - columns.add(primary); - columns.add(new Column(key, value)); - owner().getSQLiteUserTable().update(primary, columns); - } else { - owner().getMysql().update(uuid.get(), key, value); - } + Objects.requireNonNull(storage, "storage"); + routed(storage, (type, target) -> { target.write(type, key, value); return null; }, () -> { + if (storage == UserStorage.SQLITE) { + ArrayList columns = new ArrayList<>(); + Column primary = primary(); + columns.add(primary); + columns.add(new Column(key, value)); + owner().getSQLiteUserTable().update(primary, columns); + } else owner().getMysql().update(uuid.get(), key, value); + return null; + }); } @Override public void writeValues(UserStorage storage, HashMap values) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - // Preserve the existing bulk-write no-op while MySQL is unavailable. - if (owner().getMysql() != null) { - ArrayList columns = new ArrayList<>(); - for (Entry entry : values.entrySet()) { - if (!entry.getKey().equals("uuid")) { - columns.add(new Column(entry.getKey(), entry.getValue())); + Objects.requireNonNull(storage, "storage"); + routed(storage, (type, target) -> { target.writeValues(type, values); return null; }, () -> { + if (storage == UserStorage.MYSQL) { + if (owner().getMysql() != null) { + ArrayList columns = new ArrayList<>(); + for (Entry entry : values.entrySet()) { + if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); } + owner().getMysql().update(uuid.get(), columns, false); } - owner().getMysql().update(uuid.get(), columns, false); - } - } else { - ArrayList columns = new ArrayList<>(); - for (Entry entry : values.entrySet()) { - if (!entry.getKey().equals("uuid")) { - columns.add(new Column(entry.getKey(), entry.getValue())); + } else { + ArrayList columns = new ArrayList<>(); + for (Entry entry : values.entrySet()) { + if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); } - // Deliberately preserve legacy cumulative per-entry updates. - // Moving this call outside the loop would change write/failure ordering. - owner().getSQLiteUserTable().update(primary(), columns); + if (!columns.isEmpty()) owner().getSQLiteUserTable().update(primary(), columns); } - } + return null; + }); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java index 79833f3a60..93cfff1131 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java @@ -99,20 +99,16 @@ public void execute(CommandSender sender, String[] args) { } final String cmd = str; - // Stream instead of building a huge users list - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + // Stream instead of building a huge users list. Shared storage rejects + // primary-thread reads, so enumeration stays on the worker. + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - Bukkit.getServer().dispatchCommand(sender, - PlaceholderUtils.replacePlaceHolder(cmd, "player", user.getPlayerName())); - } - }); - }, null); + user.updateTempCacheWithColumns(columns); + String playerName = user.getPlayerName(); + plugin.getBukkitScheduler().runTask(plugin, () -> Bukkit.getServer().dispatchCommand(sender, + PlaceholderUtils.replacePlaceHolder(cmd, "player", playerName))); + }, null), null); } }); @@ -153,7 +149,7 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - if (plugin.getOptions().getStorageType().equals(UserStorage.MYSQL)) { + if (plugin.getStorageType().equals(UserStorage.MYSQL)) { for (UserDataKey key : plugin.getUserManager().getDataManager().getKeys()) { plugin.getMysql().alterColumnType(key.getKey(), key.getColumnType()); } @@ -170,7 +166,10 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - sendMessage(sender, "Total number of users: " + plugin.getUserManager().getAllUUIDs().size()); + runUserStorageCommand(sender, () -> { + int count = plugin.getUserManager().getAllUUIDs().size(); + runCommandCallback(sender, () -> sendMessage(sender, "Total number of users: " + count)); + }, null); } }); @@ -181,21 +180,13 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { Reward reward = plugin.getRewardHandler().getReward(args[1]); - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - // safest: many reward actions touch Bukkit API - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - new RewardBuilder(reward).send(user); - } - }); - }, (count) -> { - sendMessage(sender, "&cGave all players reward file " + args[1]); - }); + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.updateTempCacheWithColumns(columns); + // Reward actions can touch Bukkit APIs, so return each one to the owner thread. + runRecipientCallback(uuid, () -> new RewardBuilder(reward).send(user)); + }, null), () -> sendMessage(sender, "&cGave all players reward file " + args[1])); } }); @@ -230,20 +221,12 @@ public void execute(CommandSender sender, String[] args) { public void executeAll(CommandSender sender, String[] args) { Reward reward = plugin.getRewardHandler().getReward(args[3]); - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - new RewardBuilder(reward).send(user); - } - }); - }, (count) -> { - sendMessage(sender, "&cGave all players reward file " + args[3]); - }); + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.updateTempCacheWithColumns(columns); + runRecipientCallback(uuid, () -> new RewardBuilder(reward).send(user)); + }, null), () -> sendMessage(sender, "&cGave all players reward file " + args[3])); } @Override @@ -280,10 +263,11 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - sendMessage(sender, "&cStarting to clear offline rewards"); - plugin.getUserManager().removeAllKeyValues(plugin.getUserManager().getOfflineRewardsPath(), - DataType.STRING); - sendMessage(sender, "&cFinished clearing offline rewards"); + sendMessage(sender, "&cStarting to clear offline rewards"); + runUserStorageCommand(sender, + () -> plugin.getUserManager().removeAllKeyValues(plugin.getUserManager().getOfflineRewardsPath(), + DataType.STRING), + () -> sendMessage(sender, "&cFinished clearing offline rewards")); } }); @@ -295,15 +279,14 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cStarting to run offline rewards"); - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - user.forceRunOfflineRewards(); - }, (count) -> { - sendMessage(sender, "&cFinished running offline rewards"); - }); + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.updateTempCacheWithColumns(columns); + // Replay remains on this worker. Individual player-affine reward actions + // marshal themselves through the reward scheduler when necessary. + user.forceRunOfflineRewards(); + }, null), () -> sendMessage(sender, "&cFinished running offline rewards")); } }); @@ -417,27 +400,18 @@ public void execute(CommandSender sender, String[] args) { } }); - cmds.add(new CommandHandler(plugin, new String[] { "UserRemove", "(player)" }, permPrefix + ".UserRemove", + cmds.add(new CommandHandler(plugin, new String[] { "UserRemove", "(player)" }, permPrefix + ".UserRemove", "Remove User") { @Override public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cRemoving " + args[1]); - // Remove user data (DB/flatfile/etc) - AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); - user.getData().remove(); - - // Remove any cached mappings (UuidLookup maintains the name<->uuid cache now) - String uuidStr = UuidLookup.getInstance().getUUID(args[1]); - if (!isBlank(uuidStr)) { - UuidLookup.getInstance().invalidate(uuidStr); - } else { - // still invalidate by name key in case it exists - UuidLookup.getInstance().invalidate(args[1]); - } - - sendMessage(sender, "&cRemoved " + args[1]); + AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); + removeUserData(sender, args[1], user, () -> { + String uuidStr = UuidLookup.getInstance().getUUID(args[1]); + UuidLookup.getInstance().invalidate(isBlank(uuidStr) ? args[1] : uuidStr); + }); } }); @@ -448,13 +422,8 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cRemoving " + args[1]); - AdvancedCoreUser user = plugin.getUserManager().getUser(UUID.fromString(args[1])); - user.getData().remove(); - - // Clear mapping from UuidLookup (no plugin uuidNameCache anymore) - UuidLookup.getInstance().invalidate(args[1]); - - sendMessage(sender, "&cRemoved " + args[1]); + AdvancedCoreUser user = plugin.getUserManager().getUser(UUID.fromString(args[1])); + removeUserData(sender, args[1], user, () -> UuidLookup.getInstance().invalidate(args[1])); } }); @@ -549,13 +518,11 @@ public void executeAll(CommandSender sender, String[] args) { final String key = args[3]; final String value = data; - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); user.getData().setString(key, value); - }, (count) -> { - sender.sendMessage(MessageAPI.colorize("&cSet all users " + key + " to " + args[4])); - }); + }, null), () -> sender.sendMessage(MessageAPI.colorize("&cSet all users " + key + " to " + args[4]))); } @Override @@ -573,12 +540,17 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { cmds.add(new CommandHandler(plugin, new String[] { "User", "(Player)", "ViewData" }, permPrefix + ".ViewData", "View playerdata") { - @Override - public void execute(CommandSender sender, String[] args) { - AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); - for (Entry entry : user.getData().getValues().entrySet()) { - sendMessage(sender, "&c&l" + entry.getKey() + " &c" + entry.getValue().toString()); - } + @Override + public void execute(CommandSender sender, String[] args) { + AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, + values -> values.forEach((key, value) -> + sendMessage(sender, "&c&l" + key + " &c" + value.toString())), + failure -> sendMessage(sender, "&cUnable to read user data; check the server log."), + callbackOwner(sender))) return; + for (Entry entry : user.getData().getValues().entrySet()) { + sendMessage(sender, "&c&l" + entry.getKey() + " &c" + entry.getValue().toString()); + } } }); @@ -629,35 +601,29 @@ public void execute(CommandSender sender, String[] args) { } }); - if (plugin.isLoadUserData()) { + if (plugin.isLoadUserData()) { cmds.add(new CommandHandler(plugin, new String[] { "ConvertToData", "(UserStorage)" }, permPrefix + ".Commands.AdminVote.ConvertToData", "Convert user storage from current storage type to the one specified", true, true) { - @Override - public void execute(CommandSender sender, String[] args) { - sendMessage(sender, - "&cStarting convert from " + plugin.getStorageType().toString() + " to " + args[1]); - plugin.convertDataStorage(plugin.getStorageType(), UserStorage.value(args[1])); - sendMessage(sender, "&cFinished converting"); - } + @Override + public void execute(CommandSender sender, String[] args) { + startStorageConversion(sender, plugin.getStorageType(), UserStorage.value(args[1])); + } }); cmds.add(new CommandHandler(plugin, new String[] { "ConvertFromData", "(UserStorage)" }, permPrefix + ".Commands.AdminVote.ConvertFromData", "Convert user storage from the specified storage type to the current one", true, true) { - @Override - public void execute(CommandSender sender, String[] args) { - sendMessage(sender, - "&cStarting convert from " + args[1] + " to " + plugin.getStorageType().toString()); - plugin.convertDataStorage(UserStorage.value(args[1]), plugin.getStorageType()); - sendMessage(sender, "&cFinished converting"); - } + @Override + public void execute(CommandSender sender, String[] args) { + startStorageConversion(sender, UserStorage.value(args[1]), plugin.getStorageType()); + } }); - } - - cmds.add(new CommandHandler(plugin, new String[] { "SetInputMethod", "(InputMethod)" }, + } + + cmds.add(new CommandHandler(plugin, new String[] { "SetInputMethod", "(InputMethod)" }, permPrefix + ".InputMethod", "Set your value request input method", false) { @Override @@ -689,10 +655,95 @@ public void execute(CommandSender sender, String[] args) { cmd.setAdvancedCoreCommand(true); } - return cmds; - } - - /** + return cmds; + } + + /** Run a shared-storage command off the primary thread and return UI work safely. */ + private void runUserStorageCommand(CommandSender sender, Runnable storageWork, Runnable onSuccess) { + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + try { + storageWork.run(); + if (onSuccess != null) runCommandCallback(sender, onSuccess); + } catch (RuntimeException | Error failure) { + if (plugin.getLogger() != null) { + plugin.getLogger().severe("Bulk user operation failed (" + failure.getClass().getSimpleName() + ")"); + } + runCommandCallback(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cUnable to process user storage; check the server log."))); + } + }); + } catch (RuntimeException | Error failure) { + if (plugin.getLogger() != null) { + plugin.getLogger().severe("Unable to schedule bulk user operation (" + failure.getClass().getSimpleName() + ")"); + } + sender.sendMessage(MessageAPI.colorize("&cUnable to process user storage; check the server log.")); + } + } + + private void runCommandCallback(CommandSender sender, Runnable callback) { + org.bukkit.entity.Entity owner = callbackOwner(sender); + if (owner == null) plugin.getBukkitScheduler().runTask(plugin, callback); + else plugin.getBukkitScheduler().runTask(plugin, callback, owner); + } + + /** + * Resolve a recipient from the global scheduler, then run player-affine work + * on that recipient's region. Offline users retain the global fallback. + */ + private void runRecipientCallback(UUID uuid, Runnable callback) { + plugin.getBukkitScheduler().runTask(plugin, () -> { + Player recipient = Bukkit.getPlayer(uuid); + if (recipient == null) callback.run(); + else plugin.getBukkitScheduler().runTask(plugin, callback, recipient); + }); + } + + private void startStorageConversion(CommandSender sender, UserStorage from, UserStorage to) { + sender.sendMessage(MessageAPI.colorize("&cStarting convert from " + from + " to " + to)); + plugin.convertDataStorageAsync(from, to).whenComplete((ignored, failure) -> { + Runnable completion = () -> { + if (failure == null) { + sender.sendMessage(MessageAPI.colorize("&cFinished converting")); + return; + } + // JDBC/provider exceptions can embed connection details. The storage + // layer records safe diagnostics; do not expose the raw exception here. + plugin.getLogger().severe("User storage conversion failed (" + + failure.getClass().getSimpleName() + ")"); + sender.sendMessage(MessageAPI.colorize("&cUser storage conversion failed; see the server log")); + }; + runCommandCallback(sender, completion); + }); + } + + /** Complete destructive user removal before reporting success or clearing identity mappings. */ + private void removeUserData(CommandSender sender, String identifier, AdvancedCoreUser user, + Runnable afterRemoval) { + java.util.function.Supplier remove = () -> { + user.getData().remove(); + return Boolean.TRUE; + }; + java.util.function.Consumer succeeded = ignored -> { + afterRemoval.run(); + sender.sendMessage(MessageAPI.colorize("&cRemoved " + identifier)); + }; + java.util.function.Consumer failed = ignored -> + sender.sendMessage(MessageAPI.colorize("&cUnable to remove " + identifier + "; check the server log.")); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(remove, succeeded, failed, + callbackOwner(sender))) return; + try { succeeded.accept(remove.get()); } + catch (RuntimeException failure) { + plugin.getLogger().severe("User removal failed (" + failure.getClass().getSimpleName() + ")"); + failed.accept(failure); + } + } + + private org.bukkit.entity.Entity callbackOwner(CommandSender sender) { + return sender instanceof Player player ? player : null; + } + + /** * Gets the basic commands. * * @param permPrefix the permission prefix diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java index 4a67dead44..e0266d04e6 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java @@ -1,7 +1,8 @@ package com.bencodez.advancedcore.command.gui; -import java.util.ArrayList; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map.Entry; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -19,8 +20,9 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardOptions; -import com.bencodez.advancedcore.api.user.AdvancedCoreUser; -import com.bencodez.simpleapi.player.PlayerUtils; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.player.PlayerUtils; +import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.valuerequest.StringListener; import com.bencodez.simpleapi.valuerequest.ValueRequest; @@ -108,51 +110,72 @@ public void onInput(Player player, String value) { } }); - inv.addButton(new BInventoryButton(new ItemBuilder("WRITABLE_BOOK").setName("Edit Data")) { - - @Override - public void onClick(ClickEvent clickEvent) { - Player player = clickEvent.getPlayer(); - EditGUI inv = new EditGUI("Edit Data, click to change"); - final AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); - for (final String key : user.getData().getKeys()) { - String value = user.getData().getValue(key); - inv.addButton(new EditGUIButton(new ItemBuilder(Material.STONE).setName(key + " = " + value), - new EditGUIValueString(key, value) { - - @Override - public void setValue(Player player, String value) { - if (value.equals("\"\"")) { - value = ""; - } - user.getData().setString(key, value); - openUserGUI(player, playerName); - } - })); - } - - inv.openInventory(player); - } - }); + inv.addButton(new BInventoryButton(new ItemBuilder("WRITABLE_BOOK").setName("Edit Data")) { + + @Override + public void onClick(ClickEvent clickEvent) { + Player player = clickEvent.getPlayer(); + final AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, + values -> openEditData(player, playerName, user, values), + failure -> player.sendMessage("Unable to read user data; check the server log."), player)) return; + openEditData(player, playerName, user, user.getData().getValues()); + } + }); inv.addButton(new BInventoryButton(new ItemBuilder(Material.PAPER).setName("&cView player data")) { - @Override - public void onClick(ClickEvent clickEvent) { - AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); - for (String key : user.getData().getKeys()) { - String str = user.getData().getValue(key); - user.sendMessage("&c&l" + key + " &c" + str); - } - } - }); + @Override + public void onClick(ClickEvent clickEvent) { + AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, + values -> sendUserData(user, values), + failure -> clickEvent.getPlayer().sendMessage("Unable to read user data; check the server log."), + clickEvent.getPlayer())) return; + sendUserData(user, user.getData().getValues()); + } + }); for (BInventoryButton button : extraButtons.values()) { inv.addButton(button); } - inv.openInventory(player); - } + inv.openInventory(player); + } + + /** Build inventories only after a deferred shared-store read returns to Bukkit's thread. */ + private void openEditData(Player player, String playerName, AdvancedCoreUser user, + HashMap values) { + EditGUI edit = new EditGUI("Edit Data, click to change"); + for (Entry entry : values.entrySet()) { + final String key = entry.getKey(); + String value = displayValue(entry.getValue()); + edit.addButton(new EditGUIButton(new ItemBuilder(Material.STONE).setName(key + " = " + value), + new EditGUIValueString(key, value) { + + @Override + public void setValue(Player player, String value) { + if (value.equals("\"\"")) value = ""; + user.getData().setString(key, value); + openUserGUI(player, playerName); + } + })); + } + edit.openInventory(player); + } + + private void sendUserData(AdvancedCoreUser user, HashMap values) { + for (Entry entry : values.entrySet()) { + user.sendMessage("&c&l" + entry.getKey() + " &c" + displayValue(entry.getValue())); + } + } + + private String displayValue(DataValue value) { + if (value == null) return ""; + if (value.isInt()) return String.valueOf(value.getInt()); + String string = value.getString(); + return string == null ? "" : string; + } /** * Open users GUI. @@ -189,4 +212,4 @@ public void onInput(Player player, String value) { private void setCurrentPlayer(Player player, String playerName) { PlayerUtils.setPlayerMeta(plugin, player, "UserGUI", playerName); } -} \ No newline at end of file +} 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 42f37f98a3..8439b2795b 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 @@ -2,6 +2,8 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; /** Supplies existing executor owners and platform cleanup without exposing game APIs. */ @@ -18,6 +20,26 @@ record Cleanup(String name, Runnable action) { ScheduledExecutorService getInventoryTimer(); ScheduledExecutorService getTimeTimer(); List beforeExecutorShutdown(); + + /** + * Completion of asynchronous pre-shutdown work started by + * {@link #beforeExecutorShutdown()}. Implementations must keep blocking work + * off server threads and expose its completion here so executor retirement + * cannot overtake it. + */ + default CompletionStage beforeExecutorShutdownCompletion() { + return CompletableFuture.completedFuture(null); + } + + /** + * Whether the current lifecycle caller may wait a bounded time for pre-shutdown + * work. Bukkit's disable thread must leave database retirement to its worker. + */ + default boolean canBlockForPreExecutorShutdown() { return true; } + + /** Maximum time a non-blocking lifecycle waits before forcing its storage worker. */ + default long deferredShutdownTimeoutMillis() { return 5_000; } + List afterExecutorGrace(); List afterExecutorShutdown(); void info(String message); 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 134d284de2..74d1f0779b 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 @@ -2,9 +2,14 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -16,7 +21,10 @@ * calls; cleanup hooks are synchronous and retain their existing failure policy. */ public final class AdvancedCoreRuntime { + private static final long PRE_SHUTDOWN_WAIT_SECONDS = 5; private final RuntimePlatform platform; + private enum CleanupState { SUCCESS, FAILURE, DEFERRED } + private record ExecutorGrace(ScheduledExecutorService timeTimer) { } public AdvancedCoreRuntime(RuntimePlatform platform) { this.platform = Objects.requireNonNull(platform, "platform"); @@ -39,31 +47,46 @@ public static ExecutorGroup createExecutors() { } } - public void shutdown() { - clean(platform.beforeExecutorShutdown()); + public void shutdown() { + clean(platform.beforeExecutorShutdown()); + CompletionStage retirement = platform.beforeExecutorShutdownCompletion(); + CleanupState retirementState = awaitCleanup(retirement, "pre-executor shutdown"); + ExecutorGrace grace = beginExecutorGrace(retirementState == CleanupState.SUCCESS); + if (retirementState == CleanupState.DEFERRED) { + finishDeferredCleanup(retirement, "pre-executor shutdown", grace); + return; + } + boolean timerForced = retirementState == CleanupState.FAILURE; + if (timerForced) shutdownNow(platform.getTimer()); + finishAfterExecutorGrace(grace, timerForced); + } + private ExecutorGrace beginExecutorGrace(boolean stopStorageTimer) { // Resolve the time-checker timer once, after the pre-shutdown actions, // just as the old lifecycle did. Other getters retain their lookup order. - ScheduledExecutorService timeTimer = platform.getTimeTimer(); - shutdown(platform.getLoginTimer()); - shutdown(platform.getTimer()); - shutdown(timeTimer); + ScheduledExecutorService timeTimer = platform.getTimeTimer(); + shutdown(platform.getLoginTimer()); + if (stopStorageTimer) shutdown(platform.getTimer()); + shutdown(timeTimer); shutdown(platform.getInventoryTimer()); - platform.info("Allowing background tasks to finish before shutdown"); - await(platform.getLoginTimer(), 2, TimeUnit.SECONDS); - await(platform.getTimer(), 2, TimeUnit.SECONDS); - await(timeTimer, 2, TimeUnit.SECONDS); + 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); + return new ExecutorGrace(timeTimer); + } - clean(platform.afterExecutorGrace()); - shutdownNow(platform.getLoginTimer()); - shutdownNow(platform.getTimer()); - shutdownNow(timeTimer); + private void finishAfterExecutorGrace(ExecutorGrace grace, boolean storageTimerAlreadyForced) { + clean(platform.afterExecutorGrace()); + shutdownNow(platform.getLoginTimer()); + if (!storageTimerAlreadyForced) shutdownNow(platform.getTimer()); + shutdownNow(grace.timeTimer()); shutdownNow(platform.getInventoryTimer()); await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); - await(platform.getTimer(), 1, TimeUnit.SECONDS); - await(timeTimer, 1, TimeUnit.SECONDS); + await(platform.getTimer(), 1, TimeUnit.SECONDS); + await(grace.timeTimer(), 1, TimeUnit.SECONDS); await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); clean(platform.afterExecutorShutdown()); } @@ -78,6 +101,101 @@ private void clean(List actions) { } } + /** + * Wait only where the platform permits it and never indefinitely. A failed or + * still-running storage retirement retains its worker: it owns the queued data + * and native provider until it has either flushed successfully or reported its + * own failure/retry outcome. + */ + private CleanupState awaitCleanup(CompletionStage completion, String component) { + if (completion == null) return CleanupState.SUCCESS; + var future = completion.toCompletableFuture(); + if (!platform.canBlockForPreExecutorShutdown() && !future.isDone()) { + return CleanupState.DEFERRED; + } + try { + if (future.isDone()) future.join(); + else future.get(PRE_SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS); + return CleanupState.SUCCESS; + } catch (TimeoutException timeout) { + platform.cleanupFailed(component, timeout); + return CleanupState.DEFERRED; + } catch (CompletionException failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + platform.cleanupFailed(component, cause); + return CleanupState.FAILURE; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + platform.cleanupFailed(component, interrupted); + return future.isDone() ? CleanupState.FAILURE : CleanupState.DEFERRED; + } catch (Exception failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + platform.cleanupFailed(component, cause); + if (future.isDone()) { + return CleanupState.FAILURE; + } + return CleanupState.DEFERRED; + } + } + + /** Finish platform teardown now and bound the remaining storage-worker retirement. */ + private void finishDeferredCleanup(CompletionStage completion, String component, ExecutorGrace grace) { + ScheduledExecutorService timer = platform.getTimer(); + // Bukkit/Folia-facing cleanup must finish on the lifecycle thread before + // onDisable returns. Only storage-executor retirement continues later. + finishDeferredPlatformCleanup(grace); + AtomicBoolean finished = new AtomicBoolean(); + completion.whenComplete((ignored, failure) -> { + if (!finished.compareAndSet(false, true)) return; + if (failure == null) shutdown(timer); + else { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() : failure; + platform.cleanupFailed(component, cause); + shutdownNow(timer); + } + finishDeferredStorageTimer(timer, failure != null); + }); + long timeoutMillis = Math.max(1, platform.deferredShutdownTimeoutMillis()); + Runnable timeout = () -> { + if (!finished.compareAndSet(false, true)) return; + platform.cleanupFailed(component, new TimeoutException( + "Deferred storage retirement exceeded " + timeoutMillis + " ms")); + shutdownNow(timer); + finishDeferredStorageTimer(timer, true); + }; + try { CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(timeout); } + catch (RuntimeException | Error schedulingFailure) { + platform.cleanupFailed("deferred storage shutdown watchdog", schedulingFailure); + timeout.run(); + } + } + + private void finishDeferredPlatformCleanup(ExecutorGrace grace) { + clean(platform.afterExecutorGrace()); + shutdownNow(platform.getLoginTimer()); + shutdownNow(grace.timeTimer()); + shutdownNow(platform.getInventoryTimer()); + await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); + await(grace.timeTimer(), 1, TimeUnit.SECONDS); + await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); + clean(platform.afterExecutorShutdown()); + } + + private void finishDeferredStorageTimer(ScheduledExecutorService timer, boolean forced) { + Runnable retirement = () -> { + await(timer, forced ? 1 : 2, TimeUnit.SECONDS); + if (!forced && timer != null && !timer.isTerminated()) shutdownNow(timer); + }; + Thread shutdownThread = new Thread(retirement, "AdvancedCore-Storage-Shutdown"); + shutdownThread.setDaemon(true); + try { shutdownThread.start(); } + catch (RuntimeException | Error startFailure) { + platform.cleanupFailed("deferred storage shutdown continuation", startFailure); + shutdownNow(timer); + } + } + public static void shutdown(ScheduledExecutorService executor) { if (executor != null && !executor.isShutdown()) executor.shutdown(); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java new file mode 100644 index 0000000000..6ae78308a6 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java @@ -0,0 +1,347 @@ +package com.bencodez.advancedcore.core.user.runtime; + +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; + +/** Coordinates one existing cache/queue and its SQL provider; storage work runs on a worker. */ +public final class SharedUserDataRuntime implements AutoCloseable { + private static final int USER_LOCK_STRIPES = 64; + private final UserCacheOwner cacheOwner; + private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock(true); + private final ReentrantReadWriteLock[] userLocks = createUserLocks(); + private final AtomicBoolean retiring = new AtomicBoolean(); + private final Object closeLock = new Object(); + private volatile SqlUserBackend backend; + /** A replacement whose close failed remains here for a later safe retry. */ + private volatile SqlUserBackend pendingBackendClose; + private volatile boolean closed; + private CompletableFuture closeAttempt; + + public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) { + this.backend = Objects.requireNonNull(backend, "backend"); + this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); + Consumer lifecycleGate = batch -> storageAccess(() -> { batch.run(); return null; }); + BiConsumer perUserGate = (uuid, batch) -> userAccess(uuid, () -> { batch.run(); return null; }); + BiConsumer exclusiveUserGate = (uuid, batch) -> userExclusiveAccess(uuid, () -> { batch.run(); return null; }); + cacheOwner.bindLifecycle(backend, lifecycleGate, perUserGate, exclusiveUserGate); + } + + public DataValue read(UUID uuid, String key, UserDataFetchMode mode, HashMap temporaryCache, DataValue defaultValue) { + Objects.requireNonNull(uuid, "uuid"); + return userAccess(uuid, () -> { + Objects.requireNonNull(mode, "mode"); + if (key == null || key.isEmpty()) return defaultValue; + if (mode.allowTempCache() && temporaryCache != null) { + DataValue temporary = temporaryCache.get(key); + if (temporary != null) return temporary; + if (!mode.allowUserCache() && !mode.allowStorageLookup()) return defaultValue; + } + if (mode.allowUserCache()) { + DataValue cached = cacheOwner.getIfPresent(uuid, key); + if (cached != null) return cached; + if (mode.allowStorageLookup() && mode.waitForCache() && !cacheOwner.isCached(uuid)) { + cacheOwner.requireBlockingAllowed(); + populateInternal(uuid); + cached = cacheOwner.getIfPresent(uuid, key); + if (cached != null) return cached; + } + if (!mode.allowStorageLookup()) return defaultValue; + } else if (!mode.allowStorageLookup()) return defaultValue; + cacheOwner.requireBlockingAllowed(); + return find(readStorageRow(uuid), key, defaultValue); + }); + } + + public HashMap populate(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + return storageUserAccess(uuid, () -> populateInternal(uuid)); + } + + private HashMap populateInternal(UUID uuid) { + UserCacheOwner.PopulationToken token = cacheOwner.beginPopulation(uuid); + if (cacheOwner.isCached(uuid)) flushInternal(uuid); + HashMap values = SqlUserDataAccess.convert(readStorageRow(uuid)); + return cacheOwner.completePopulation(uuid, values, token); + } + + public int startupForEach(BiConsumer> consumer, boolean populateCache) { + return storageAccess(() -> { + Objects.requireNonNull(consumer, "consumer"); + int[] count = { 0 }; + backend.forEachUser(uuid -> { + HashMap values = userAccess(uuid, () -> populateCache ? populateInternal(uuid) : SqlUserDataAccess.convert(readStorageRow(uuid))); + // External callbacks run after releasing the per-user read lock so they + // may safely remove or otherwise exclusively mutate this user. + consumer.accept(uuid, values); + count[0]++; + }); + return count[0]; + }); + } + + public void queueChange(UUID uuid, String key, DataValue value) { + Objects.requireNonNull(uuid, "uuid"); + userAccess(uuid, () -> { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + if (!cacheOwner.isCached(uuid)) { + cacheOwner.requireBlockingAllowed(); + populateInternal(uuid); + } + cacheOwner.queueChange(uuid, key, value); + return null; + }); + } + + public void flush(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + try { storageUserAccess(uuid, () -> { flushInternal(uuid); return null; }); } + finally { cacheOwner.dispatchNotifications(uuid); } + } + + private void flushInternal(UUID uuid) { cacheOwner.flush(uuid, backend.storageType(), backend.user(uuid)); } + + public void flushAll() { + try { storageAccess(() -> { + for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) userAccess(uuid, () -> { flushInternal(uuid); return null; }); + return null; + }); } finally { cacheOwner.dispatchAllNotifications(); } + } + + /** + * Run an explicitly requested native-storage maintenance operation while no + * shared cache read, write, replacement, or shutdown operation can overlap + * it. The caller supplies the storage-specific work; this runtime first + * durably flushes and retires its cache generation so a provider change + * cannot split queued updates across the old and replacement owners. + */ + public void runStorageMaintenance(Runnable operation) { + Objects.requireNonNull(operation, "operation"); + rejectReentrantTransition(); + cacheOwner.requireBlockingAllowed(); + lifecycle.writeLock().lock(); + try { + requireOpen(); + cacheOwner.beginRetirement(); + try { + flushAllInternal(); + cacheOwner.clearAfterFlush(); + operation.run(); + } catch (RuntimeException | Error failure) { + cacheOwner.cancelRetirement(); + throw failure; + } + } finally { + lifecycle.writeLock().unlock(); + cacheOwner.dispatchAllNotifications(); + } + } + + private void flushAllInternal() { for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) flushInternal(uuid); } + + public void replaceBackend(SqlUserBackend replacement) { + replaceBackend(replacement, () -> {}); + } + + /** Replace the route and publish its platform owner before releasing lifecycle admission. */ + public void replaceBackend(SqlUserBackend replacement, Runnable afterReplacement) { + rejectReentrantTransition(); + cacheOwner.requireBlockingAllowed(); + Objects.requireNonNull(replacement, "replacement"); + Objects.requireNonNull(afterReplacement, "afterReplacement"); + lifecycle.writeLock().lock(); + try { + requireOpen(); + retryPendingBackendClose(); + if (replacement == backend) return; + if (!replacement.isOpen()) throw new IllegalArgumentException("replacement backend is closed"); + cacheOwner.beginRetirement(); + try { + flushAllInternal(); + cacheOwner.clearAfterFlush(); + SqlUserBackend previous = backend; + // Publish the native owner before its route. The cache owner captures the + // owner snapshot together with the new route, so public bulk APIs never + // combine a new route type with the old mutable provider fields. + afterReplacement.run(); + cacheOwner.bindBackend(replacement); + backend = replacement; + try { previous.close(); } + catch (RuntimeException | Error failure) { + // The replacement is already published and owns the active route. + // Retain the old backend for a later close retry, but never report this + // as a failed replacement: callers must not tear down the live owner. + pendingBackendClose = previous; + } + } catch (RuntimeException | Error failure) { + cacheOwner.cancelRetirement(); + throw failure; + } + } finally { + lifecycle.writeLock().unlock(); + cacheOwner.dispatchAllNotifications(); + } + } + + private void retryPendingBackendClose() { + SqlUserBackend pending = pendingBackendClose; + if (pending == null) return; + pending.close(); + pendingBackendClose = null; + } + + public void remove(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + cacheOwner.requireBlockingAllowed(); + try { + userExclusiveAccess(uuid, () -> { + cacheOwner.beginRemoval(uuid); + try { + flushInternal(uuid); + backend.user(uuid).delete(backend.storageType()); + cacheOwner.remove(uuid); + } catch (RuntimeException | Error failure) { + cacheOwner.cancelRemoval(uuid); + throw failure; + } + return null; + }); + } finally { cacheOwner.dispatchNotifications(uuid); } + } + + public SqlUserBackend backend() { return backend; } + public boolean isClosed() { return closed; } + public boolean isRetiring() { return retiring.get(); } + + /** Admit a native bulk operation for the life of its provider access. */ + public T withStorageReadAdmission(Supplier operation) { + Objects.requireNonNull(operation, "operation"); + return storageAccess(operation); + } + + public CompletionStage closeAsync(Executor executor) { + Objects.requireNonNull(executor, "executor"); + rejectReentrantTransition(); + CompletableFuture result; + synchronized (closeLock) { + if (closeAttempt != null && !closeAttempt.isCompletedExceptionally()) return closeAttempt.minimalCompletionStage(); + retiring.set(true); + result = new CompletableFuture<>(); + closeAttempt = result; + } + try { + executor.execute(() -> { + boolean terminallyClosed = false; + try { + cacheOwner.requireBlockingAllowed(); + lifecycle.writeLock().lock(); + try { + if (!closed) { + cacheOwner.beginRetirement(); + flushAllInternal(); + cacheOwner.clearAfterFlush(); + cacheOwner.shutdown(); + retryPendingBackendClose(); + backend.close(); + closed = true; + terminallyClosed = true; + } + } finally { + lifecycle.writeLock().unlock(); + if (terminallyClosed) cacheOwner.discardAllNotifications(); + else cacheOwner.dispatchAllNotifications(); + } + result.complete(null); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); + } catch (RuntimeException | Error failure) { result.completeExceptionally(failure); } + return result.minimalCompletionStage(); + } + + @Override + public void close() { + if (closed) return; + cacheOwner.requireBlockingAllowed(); + try { closeAsync(Runnable::run).toCompletableFuture().join(); } + catch (CompletionException failure) { + if (failure.getCause() instanceof RuntimeException runtime) throw runtime; + if (failure.getCause() instanceof Error error) throw error; + throw failure; + } + } + + private T access(Supplier operation) { + boolean admitted = lifecycle.getReadHoldCount() > 0 || lifecycle.isWriteLockedByCurrentThread(); + if (!admitted) requireOpen(); + lifecycle.readLock().lock(); + try { + if (!admitted) requireOpen(); + return operation.get(); + } finally { lifecycle.readLock().unlock(); } + } + + private T storageAccess(Supplier operation) { cacheOwner.requireBlockingAllowed(); return access(operation); } + + private T userAccess(UUID uuid, Supplier operation) { + return access(() -> { + ReentrantReadWriteLock.ReadLock lock = userLock(uuid).readLock(); + lock.lock(); + try { return operation.get(); } finally { lock.unlock(); } + }); + } + + private T storageUserAccess(UUID uuid, Supplier operation) { cacheOwner.requireBlockingAllowed(); return userAccess(uuid, operation); } + + private T userExclusiveAccess(UUID uuid, Supplier operation) { + return access(() -> { + ReentrantReadWriteLock.WriteLock lock = userLock(uuid).writeLock(); + lock.lock(); + try { return operation.get(); } finally { lock.unlock(); } + }); + } + + private ReentrantReadWriteLock userLock(UUID uuid) { + int index = (uuid.hashCode() & Integer.MAX_VALUE) % USER_LOCK_STRIPES; + return userLocks[index]; + } + + private static ReentrantReadWriteLock[] createUserLocks() { + ReentrantReadWriteLock[] locks = new ReentrantReadWriteLock[USER_LOCK_STRIPES]; + for (int i = 0; i < locks.length; i++) locks[i] = new ReentrantReadWriteLock(true); + return locks; + } + + private void rejectReentrantTransition() { + if (lifecycle.getReadHoldCount() > 0 || lifecycle.isWriteLockedByCurrentThread()) throw new IllegalStateException("Cannot replace or close the user runtime from an active user callback"); + } + + private List readStorageRow(UUID uuid) { return backend.user(uuid).readRow(backend.storageType()); } + + private DataValue find(List row, String key, DataValue defaultValue) { + if (row != null) for (Column column : row) if (column.getName().equals(key)) return column.getValue() == null ? defaultValue : column.getValue(); + return defaultValue; + } + + private void requireOpen() { + if (retiring.get()) throw new IllegalStateException("Shared user data runtime is retiring or closed"); + if (!backend.isOpen()) throw new IllegalStateException("SQL user backend is closed"); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java new file mode 100644 index 0000000000..81038cf0bf --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java @@ -0,0 +1,80 @@ +package com.bencodez.advancedcore.core.user.runtime; + +import java.util.HashMap; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.data.DataValue; + +/** Port for the existing cache/queue owner. No parallel cache is allocated. */ +public interface UserCacheOwner { + boolean isCached(UUID uuid); + DataValue getIfPresent(UUID uuid, String key); + void populate(UUID uuid, HashMap values); + + interface PopulationToken {} + + default PopulationToken beginPopulation(UUID uuid) { return null; } + + default HashMap completePopulation(UUID uuid, HashMap values, + PopulationToken token) { + populate(uuid, values); + return values; + } + + void queueChange(UUID uuid, String key, DataValue value); + void flush(UUID uuid, SqlUserStorage storage); + + default void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { flush(uuid, storage); } + + default void bindFlushGate(Consumer gate) {} + default void bindUserGate(BiConsumer gate) {} + default void bindBackend(SqlUserBackend backend) {} + + default void bindLifecycle(SqlUserBackend backend, Consumer gate) { + bindFlushGate(gate); + bindBackend(backend); + } + + /** Publish the global and per-user lifecycle routes as one logical binding. */ + default void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer userGate) { + bindUserGate(userGate); + bindLifecycle(backend, gate); + } + + /** Also supplies exclusive per-user admission for legacy cache removal. */ + default void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer userGate, BiConsumer exclusiveUserGate) { + bindLifecycle(backend, gate, userGate); + } + + default void requireBlockingAllowed() {} + + /** Deliver callbacks accumulated by a flush after its user admission is released. */ + default void dispatchNotifications(UUID uuid) {} + + /** Deliver callbacks accumulated by a lifecycle-wide flush after all admission is released. */ + default void dispatchAllNotifications() {} + + /** Discard callbacks after terminal shutdown; unloaded consumers must not be invoked. */ + default void discardAllNotifications() {} + + Set cachedUsers(); + /** Fence direct cache publishers before an exclusive delete begins. */ + default void beginRemoval(UUID uuid) {} + /** Reopen a cache when its exclusive delete did not complete. */ + default void cancelRemoval(UUID uuid) {} + /** Fence direct publishers across a runtime-wide flush and retirement. */ + default void beginRetirement() {} + /** Reopen caches when a runtime-wide transition aborts before retirement. */ + default void cancelRetirement() {} + void remove(UUID uuid); + void clearAfterFlush(); + void shutdown(); +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java new file mode 100644 index 0000000000..34832542d5 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java @@ -0,0 +1,78 @@ +package com.bencodez.advancedcore; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.simpleapi.command.TabCompleteHandle; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class AdvancedCoreUuidTabCompletionRefreshTest { + @Test + void storageEnumerationIsDeferredAndTheLatestRefreshWins() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + TabCompleteHandle handle = mock(TabCompleteHandle.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getOptions()).thenReturn(options); + when(options.isOnlineMode()).thenReturn(true); + when(handle.getToReplace()).thenReturn("(uuid)"); + when(users.getAllUUIDs()).thenReturn(new ArrayList<>(List.of("old")), new ArrayList<>(List.of("new"))); + + ArrayList workers = new ArrayList<>(); + ArrayList mainTasks = new ArrayList<>(); + doAnswer(call -> { + workers.add(call.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { + mainTasks.add(call.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(any(), any()); + + AdvancedCorePlugin.UuidTabCompletionRefresh refresh = new AdvancedCorePlugin.UuidTabCompletionRefresh(); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + + refresh.request(plugin, handle); + verify(users, never()).getAllUUIDs(); + assertEquals(1, workers.size(), "request must enqueue, not enumerate on the caller thread"); + + refresh.request(plugin, handle); + assertEquals(1, workers.size(), "overlapping requests must be coalesced"); + + workers.remove(0).run(); + verify(users).getAllUUIDs(); + assertEquals(1, mainTasks.size(), "worker result must be applied on the global scheduler"); + verify(handle, never()).setReplace(any()); + + mainTasks.remove(0).run(); + verify(handle, never()).setReplace(any()); + assertEquals(1, workers.size(), "the stale completion must schedule the newest generation"); + + workers.remove(0).run(); + assertEquals(1, mainTasks.size()); + mainTasks.remove(0).run(); + + ArgumentCaptor> replacement = ArgumentCaptor.forClass(ArrayList.class); + verify(handle).setReplace(replacement.capture()); + assertEquals(List.of("new"), replacement.getValue()); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java new file mode 100644 index 0000000000..64125590d4 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java @@ -0,0 +1,40 @@ +package com.bencodez.advancedcore.api.rewards; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RewardOptionsTest { + + @Test + void nestedDispatchPreservesCapturedLivePlayerState() { + RewardOptions nested = new RewardOptions().captureLivePlayerState(false, true) + .copyForNestedDispatch("parent/child:0"); + + assertTrue(nested.isLivePlayerStateSet()); + assertFalse(nested.isOnline()); + assertTrue(nested.isLivePlayerVanished()); + } + + @Test + void builtInNestedDispatchInheritsCapturedLivePlayerStateFromReplay() { + RewardOptions parent = new RewardOptions().captureLivePlayerState(false, true); + RewardOptions child = Reward.withReplayState(new RewardOptions(), Reward.replayStateFor(parent), + "parent", "child:0", "occurrence"); + + assertTrue(child.isLivePlayerStateSet()); + assertFalse(child.isOnline()); + assertTrue(child.isLivePlayerVanished()); + } + + @Test + void deferredChildHelperInheritsCapturedLivePlayerStateFromReplay() { + RewardOptions parent = new RewardOptions().captureLivePlayerState(false, true); + RewardOptions child = Reward.withReplayState(new RewardOptions(), Reward.replayStateFor(parent)); + + assertTrue(child.isLivePlayerStateSet()); + assertFalse(child.isOnline()); + assertTrue(child.isLivePlayerVanished()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java index 45655b78e6..331e8a6cb9 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java @@ -1,15 +1,22 @@ package com.bencodez.advancedcore.command; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; @@ -18,9 +25,183 @@ import com.bencodez.advancedcore.api.command.CommandHandler; import com.bencodez.advancedcore.api.command.PlayerCommandHandler; import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.scheduler.BukkitScheduler; class CommandLoaderBulkPermissionTest { + @Test + void totalUsersDefersStorageAndReturnsResultOrErrorOnTheMainScheduler() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getAllUUIDs()).thenReturn(new ArrayList<>(List.of("one", "two"))); + ArrayList workers = new ArrayList<>(); + ArrayList callbacks = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { callbacks.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTask(any(), any()); + CommandHandler command = find(new CommandLoader(plugin), "TotalNumberOfUsers"); + + command.execute(sender, new String[] { "TotalNumberOfUsers" }); + verify(users, never()).getAllUUIDs(); + assertEquals(1, workers.size()); + workers.remove(0).run(); + verify(users).getAllUUIDs(); + assertEquals(1, callbacks.size()); + callbacks.remove(0).run(); + verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("Total number of users: 2")); + + when(users.getAllUUIDs()).thenThrow(new IllegalStateException("storage unavailable")); + command.execute(sender, new String[] { "TotalNumberOfUsers" }); + workers.remove(0).run(); + callbacks.remove(0).run(); + verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("Unable to process user storage")); + } + + @Test + void runCmdAllAndGiveAllDeferEnumerationBeforeSchedulingBukkitActions() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + RewardHandler rewards = mock(RewardHandler.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ArrayList workers = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + CommandLoader loader = new CommandLoader(plugin); + + find(loader, "RunCMD", "All", "(List)").execute(sender, new String[] { "RunCMD", "All", "say", "hi" }); + find(loader, "GiveAll", "(reward)").execute(sender, new String[] { "GiveAll", "daily" }); + verify(users, never()).forEachUserKeys(any(), any()); + assertEquals(2, workers.size()); + for (Runnable worker : List.copyOf(workers)) worker.run(); + verify(users, org.mockito.Mockito.times(2)).forEachUserKeys(any(), any()); + } + + @Test + void giveAllUsesTheRecipientEntitySchedulerForOnlineUsers() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + RewardHandler rewards = mock(RewardHandler.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + Player recipient = mock(Player.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getUser(uuid, false)).thenReturn(user); + ArrayList workers = new ArrayList<>(); + ArrayList globalCallbacks = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { globalCallbacks.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTask(any(), any()); + doAnswer(call -> { + @SuppressWarnings("unchecked") BiConsumer> perUser = call.getArgument(0, BiConsumer.class); + perUser.accept(uuid, new ArrayList<>()); + return null; + }).when(users).forEachUserKeys(any(), any()); + find(new CommandLoader(plugin), "GiveAll", "(reward)").execute(sender, new String[] { "GiveAll", "daily" }); + workers.remove(0).run(); + assertEquals(2, globalCallbacks.size()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(uuid)).thenReturn(recipient); + globalCallbacks.remove(0).run(); + } + verify(scheduler).runTask(any(), any(Runnable.class), org.mockito.ArgumentMatchers.same(recipient)); + } + + @Test + void runCmdAllUsesTheGlobalSchedulerEvenForAPlayerIssuer() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + Player sender = mock(Player.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlayerName()).thenReturn("voter"); + ArrayList workers = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { + @SuppressWarnings("unchecked") BiConsumer> perUser = call.getArgument(0, BiConsumer.class); + perUser.accept(uuid, new ArrayList<>()); + return null; + }).when(users).forEachUserKeys(any(), any()); + + find(new CommandLoader(plugin), "RunCMD", "All", "(List)") + .execute(sender, new String[] { "RunCMD", "All", "say", "hi" }); + workers.remove(0).run(); + + verify(scheduler).runTask(any(), any(Runnable.class)); + verify(scheduler, never()).runTask(any(), any(Runnable.class), org.mockito.ArgumentMatchers.same(sender)); + } + + @Test + void remainingBulkCommandsDoNotTouchWorkerOnlyStorageBeforeScheduling() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + RewardHandler rewards = mock(RewardHandler.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + AdvancedCoreUser offlineUser = mock(AdvancedCoreUser.class); + UUID offlineUuid = UUID.randomUUID(); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + when(users.getUser(offlineUuid, false)).thenReturn(offlineUser); + ArrayList workers = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { + @SuppressWarnings("unchecked") BiConsumer> perUser = call.getArgument(0, BiConsumer.class); + perUser.accept(offlineUuid, new ArrayList<>()); + return null; + }).when(users).forEachUserKeys(any(), any()); + CommandLoader loader = new CommandLoader(plugin); + find(loader, "ClearOfflineRewards").execute(sender, new String[] { "ClearOfflineRewards" }); + find(loader, "ForceRunOfflineRewards").execute(sender, new String[] { "ForceRunOfflineRewards" }); + ((PlayerCommandHandler) find(loader, "User", "(Player)", "ForceReward", "(Reward)")) + .executeAll(sender, new String[] { "User", "all", "ForceReward", "daily" }); + ((PlayerCommandHandler) find(loader, "User", "(player)", "SetData", "(text)", "(text)")) + .executeAll(sender, new String[] { "User", "all", "SetData", "rank", "trusted" }); + verify(users, never()).removeAllKeyValues(any(), any()); + verify(users, never()).forEachUserKeys(any(), any()); + assertEquals(4, workers.size()); + workers.get(1).run(); + verify(offlineUser).forceRunOfflineRewards(); + } + + private static CommandHandler find(CommandLoader loader, String... args) { + return loader.getBasicAdminCommands("Example").stream() + .filter(handler -> Arrays.equals(handler.getArgs(), args)).findFirst().orElseThrow(); + } + @Test void setDataBulkUsesTheSharedBaseAndAllAuthorization() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); 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 31a2cb53f1..31e15ef1cb 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 @@ -6,14 +6,18 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletionException; 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.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.platform.RuntimePlatform; @@ -27,6 +31,8 @@ private RuntimePlatform platform() { when(platform.beforeExecutorShutdown()).thenReturn(List.of()); when(platform.afterExecutorGrace()).thenReturn(List.of()); when(platform.afterExecutorShutdown()).thenReturn(List.of()); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(true); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(5_000L); return platform; } @@ -72,7 +78,7 @@ private ScheduledExecutorService executor(String name, List events) thro return executor; } - @Test void cleanupFailureIsReportedWithoutSkippingLaterComponents() { + @Test void cleanupFailureIsReportedWithoutSkippingLaterComponents() { RuntimePlatform platform = platform(); var events = new ArrayList(); var failure = new IllegalStateException("fixture"); @@ -83,7 +89,122 @@ private ScheduledExecutorService executor(String name, List events) thro new AdvancedCoreRuntime(platform).shutdown(); assertEquals(List.of("next", "last"), events); verify(platform).cleanupFailed("failed", failure); - } + } + + @Test void waitsForAsyncPreShutdownWorkBeforeRetiringExecutors() throws Exception { + RuntimePlatform platform = platform(); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + ScheduledExecutorService timer = executor("timer", events); + CompletableFuture retiring = new CompletableFuture<>(); + when(platform.beforeExecutorShutdown()).thenReturn(List.of(new Cleanup("pre", () -> events.add("pre")))); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.getTimer()).thenReturn(timer); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var shutdown = worker.submit(() -> new AdvancedCoreRuntime(platform).shutdown()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!events.contains("pre") && System.nanoTime() < deadline) Thread.yield(); + assertEquals(List.of("pre"), events, "executor shutdown must wait for storage retirement"); + retiring.complete(null); + shutdown.get(5, TimeUnit.SECONDS); + assertTrue(events.indexOf("timer-stop") > events.indexOf("pre")); + } finally { + retiring.complete(null); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void nonBlockingPlatformLeavesStorageWorkerAliveUntilRetirementCompletes() throws Exception { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.getTimer()).thenReturn(timer); + java.util.concurrent.atomic.AtomicReference cleanupThread = new java.util.concurrent.atomic.AtomicReference<>(); + when(platform.afterExecutorGrace()).thenReturn(List.of( + new Cleanup("reward", () -> events.add("reward")))); + when(platform.afterExecutorShutdown()).thenReturn(List.of( + new Cleanup("unload", () -> { cleanupThread.set(Thread.currentThread()); events.add("unload"); }))); + + Thread lifecycleThread = Thread.currentThread(); + assertDoesNotThrow(() -> new AdvancedCoreRuntime(platform).shutdown()); + verify(timer, never()).shutdown(); + verify(timer, never()).shutdownNow(); + verify(timer, never()).awaitTermination(anyLong(), any()); + assertEquals(List.of("reward", "unload"), events); + assertSame(lifecycleThread, cleanupThread.get(), + "Bukkit-facing cleanup must finish on the lifecycle thread before disable returns"); + retiring.complete(null); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (mockingDetails(timer).getInvocations().stream() + .noneMatch(invocation -> invocation.getMethod().getName().equals("shutdown")) + && System.nanoTime() < deadline) Thread.yield(); + verify(timer).shutdown(); + assertEquals(List.of("reward", "unload"), events, "deferred completion must not repeat cleanup"); + } + + @Test void deferredRetirementTimeoutForcesStorageWorkerWithoutRepeatingPlatformCleanup() { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(20L); + when(platform.getTimer()).thenReturn(timer); + when(platform.afterExecutorShutdown()).thenReturn(List.of( + new Cleanup("unload", () -> events.add("unload")))); + + new AdvancedCoreRuntime(platform).shutdown(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (mockingDetails(timer).getInvocations().stream() + .noneMatch(invocation -> invocation.getMethod().getName().equals("shutdownNow")) + && System.nanoTime() < deadline) Thread.yield(); + + verify(timer).shutdownNow(); + verify(platform).cleanupFailed(eq("pre-executor shutdown"), any(java.util.concurrent.TimeoutException.class)); + assertEquals(List.of("unload"), events); + retiring.complete(null); + assertEquals(List.of("unload"), events); + } + + @Test void deferredRetirementFailureTerminatesItsWorkerAfterReportingTheFailure() { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.getTimer()).thenReturn(timer); + + new AdvancedCoreRuntime(platform).shutdown(); + verify(timer, never()).shutdown(); + verify(timer, never()).shutdownNow(); + IllegalStateException failure = new IllegalStateException("write failed"); + retiring.completeExceptionally(failure); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (mockingDetails(timer).getInvocations().stream() + .noneMatch(invocation -> invocation.getMethod().getName().equals("shutdownNow")) + && System.nanoTime() < deadline) Thread.yield(); + verify(platform).cleanupFailed("pre-executor shutdown", failure); + verify(timer).shutdownNow(); + } + + @Test void failedRetirementTerminatesStorageWorkerAfterReportingTheFailure() { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + retiring.completeExceptionally(new IllegalStateException("write failed")); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.getTimer()).thenReturn(timer); + + new AdvancedCoreRuntime(platform).shutdown(); + verify(timer, never()).shutdown(); + verify(timer).shutdownNow(); + verify(platform).cleanupFailed(eq("pre-executor shutdown"), any(IllegalStateException.class)); + } @Test void preservesInterruptAndSkipsAlreadyFinishedExecutors() throws Exception { ScheduledExecutorService executor = mock(ScheduledExecutorService.class); @@ -132,32 +253,70 @@ private ScheduledExecutorService executor(String name, List events) thro verify(handler).shutdown(); assertTrue(platform.beforeExecutorShutdown().stream() .noneMatch(cleanup -> cleanup.name().equals("MySQL"))); - platform.afterExecutorShutdown().stream() - .filter(cleanup -> cleanup.name().equals("MySQL")) + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("user storage")) .findFirst().orElseThrow().action().run(); verify(mysql).close(); assertTrue(platform.afterExecutorShutdown().stream() - .noneMatch(cleanup -> cleanup.name().equals("full inventory handler"))); + .noneMatch(cleanup -> cleanup.name().equals("full inventory handler") + || cleanup.name().equals("MySQL"))); } - @Test void bukkitAdapterDoesNotCloseMysqlWhileCheckpointTasksRemainActive() { + @Test void bukkitAdapterDefersMysqlCloseUntilSharedStorageRetires() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); MySQL mysql = mock(MySQL.class); + UserManager users = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); - ScheduledExecutorService timer = mock(ScheduledExecutorService.class); when(plugin.isLoadUserData()).thenReturn(true); when(plugin.getOptions()).thenReturn(options); when(options.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(mysql); - when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); - when(plugin.getTimer()).thenReturn(timer); - when(timer.isTerminated()).thenReturn(false); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(dataManager); + Runnable[] afterRetirement = new Runnable[1]; + CompletableFuture retired = new CompletableFuture<>(); + when(dataManager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenAnswer(call -> { + afterRetirement[0] = call.getArgument(0, Runnable.class); + return retired; + }); BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); - platform.afterExecutorShutdown().stream() - .filter(cleanup -> cleanup.name().equals("MySQL")) + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("user storage")) .findFirst().orElseThrow().action().run(); - verify(mysql, never()).close(); + assertSame(retired, platform.beforeExecutorShutdownCompletion()); + assertNotNull(afterRetirement[0]); + afterRetirement[0].run(); + retired.complete(null); + verify(mysql).close(); + } + + @Test void bukkitAdapterClosesTheCapturedMysqlOwnerAfterConfigurationChanges() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + UserManager users = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(dataManager); + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(dataManager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return CompletableFuture.completedFuture(null); + }); + + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + + verify(mysql).close(); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java index d8683af4a4..c7e2e4d3a4 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java @@ -107,6 +107,10 @@ void setUp() { invocation.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); when(plugin.getBukkitScheduler()).thenReturn(scheduler); AdvancedCorePlugin.setInstance(plugin); try { @@ -530,6 +534,95 @@ void replayExperienceRemainsPendingForNullAndDisconnectedPlayers() throws Except verify(player, never()).giveExp(5); } + @Test + void capturedReplayStateAvoidsWorkerThreadBukkitPlayerReads() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(false); + when(config.isTreatVanishAsOffline()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + RewardOptions options = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true); + options.captureLivePlayerState(false, true); + options.setTimedQueueReplay(true); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + verify(user, never()).isOnline(); + verify(user, never()).isVanished(); + } + + @Test + void rewardPreparationRunsOnPlayerOwnerScheduler() { + Player player = mock(Player.class); + AtomicReference playerAccessThread = new AtomicReference<>(); + when(user.getPlayer()).thenAnswer(invocation -> { + playerAccessThread.set(Thread.currentThread()); + return player; + }); + when(player.getDisplayName()).thenReturn("Display"); + when(user.getPlayerName()).thenReturn("Queued"); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + doAnswer(invocation -> { + Thread ownerThread = new Thread(invocation.getArgument(1, Runnable.class), "reward-owner-thread"); + ownerThread.start(); + ownerThread.join(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + + reward.giveRewardUserAsync(user, new HashMap<>(), new RewardOptions()).toCompletableFuture().join(); + + assertNotNull(playerAccessThread.get()); + assertEquals("reward-owner-thread", playerAccessThread.get().getName()); + } + + @Test + void asyncRewardSnapshotsCallerPlaceholdersBeforeOwnerSchedulerHandoff() { + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(user.getPlayerName()).thenReturn("Queued"); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(player.getDisplayName()).thenReturn("Display"); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicReference observed = new AtomicReference<>(); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + observed.set(ignoredPlaceholders.get("custom")); + return CompletableFuture.completedFuture(null); + } + }); + HashMap callerPlaceholders = new HashMap<>(); + callerPlaceholders.put("custom", "original"); + + CompletionStage delivery = reward.giveRewardUserAsync(user, callerPlaceholders, new RewardOptions()); + callerPlaceholders.clear(); + callerPlaceholders.put("custom", "mutated"); + assertFalse(delivery.toCompletableFuture().isDone()); + assertEquals(1, queued.size()); + + while (!delivery.toCompletableFuture().isDone() && !queued.isEmpty()) queued.remove(0).run(); + + delivery.toCompletableFuture().join(); + assertEquals("original", observed.get()); + } + @Test void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); @@ -672,6 +765,72 @@ void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { } } + @Test + void asyncRewardRequirementsRunOnPlayerOwnerScheduler() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(user.isOnline()).thenReturn(true); + AtomicReference requirementThread = new AtomicReference<>(); + handler.getInjectedRequirements().add(new RequirementInject("OwnerThread") { + @Override + public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, RewardOptions ignoredOptions) { + requirementThread.set(Thread.currentThread().getName()); + return false; + } + }); + ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); + doAnswer(invocation -> { + Thread ownerThread = new Thread(invocation.getArgument(1, Runnable.class), "player-owner-thread"); + ownerThread.start(); + ownerThread.join(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletionStage result = reward.giveRewardAsync(user, new RewardOptions().setCheckTimed(false)); + + verify(scheduler).runTaskAsynchronously(eq(plugin), asyncTask.capture()); + asyncTask.getValue().run(); + result.toCompletableFuture().join(); + } + + assertEquals("player-owner-thread", requirementThread.get()); + } + + @Test + void terminalRequirementDenialDoesNotEnterPauseDeferral() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.getPlayer()).thenReturn(mock(Player.class)); + when(user.isOnline()).thenReturn(true); + handler.getInjectedRequirements().add(new RequirementInject("Denied") { + @Override + public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, RewardOptions ignoredOptions) { + return false; + } + }); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + reward.giveRewardAsync(user, new RewardOptions().setCheckTimed(false)).toCompletableFuture().join(); + } + + verify(user, never()).addOfflineRewards(any(), any(), any()); + } + @Test void timedOutPrimaryThreadHandoffCannotRunLateRewardSideEffects() throws Exception { Reward shortTimeoutReward = new Reward("AsyncReward", data) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java index c6c9e055d8..45bfb950db 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java @@ -51,7 +51,7 @@ void constructionDoesNotResolveOwnersAndReadUsesCurrentTableAndUuid() { } @Test - void sqliteBulkPreservesPerEntryCallsCumulativeListAndUuidExclusion() { + void sqliteBulkUsesOneUpdateWithAllNonUuidColumns() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); UserTable table = mock(UserTable.class); when(plugin.getSQLiteUserTable()).thenReturn(table); @@ -70,9 +70,9 @@ void sqliteBulkPreservesPerEntryCallsCumulativeListAndUuidExclusion() { values.put("uuid", new DataValueString("ignored-id")); values.put("PlayerName", new DataValueString("Ben")); storage.writeValues(UserStorage.SQLITE, values); - assertEquals(List.of(List.of("Points"), List.of("Points"), List.of("Points", "PlayerName")), writes); + assertEquals(List.of(List.of("Points", "PlayerName")), writes); storage.writeValues(UserStorage.SQLITE, new HashMap<>()); - assertEquals(3, writes.size()); + assertEquals(1, writes.size()); } @Test diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java index d6cda4f2c0..7feaba7e9d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java @@ -27,29 +27,25 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; class SqlUserDataFacadeTest { - @Test - void legacyRowAndConvertOverridesRemainVirtualWithoutPluginAccess() { + @Test void legacyRowAndConvertOverridesRemainVirtualWithoutPluginAccess() { AdvancedCoreUser user = mock(AdvancedCoreUser.class); ArrayList row = new ArrayList<>(List.of(new Column("Points", new DataValueInt(17)))); HashMap converted = new HashMap<>(); UserData data = new UserData(user) { @Override public List getMySqlRow() { return row; } @Override public List getSQLiteRow() { return row; } - @Override public HashMap convert(List columns) { - assertSame(row, columns); - return converted; - } + @Override public HashMap convert(List columns) { assertSame(row, columns); return converted; } }; for (UserStorage storage : UserStorage.values()) { assertEquals(17, data.getInt(storage, "Points", 0, UserDataFetchMode.NO_CACHE)); assertEquals(List.of("Points"), data.getKeys(storage)); assertSame(converted, data.getValues(storage)); } - verifyNoInteractions(user); + verify(user, atLeastOnce()).getPlugin(); + verifyNoMoreInteractions(user); } - @Test - void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { + @Test void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { for (UserDataFetchMode mode : UserDataFetchMode.values()) { Fixture f = new Fixture(); UserDataCache cache = mock(UserDataCache.class); @@ -71,8 +67,7 @@ void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { } } - @Test - void queuedCachedWriteDoesNotReachSqlOrCreateAnotherQueue() { + @Test void queuedCachedWriteDoesNotReachSqlOrCreateAnotherQueue() { Fixture f = new Fixture(); UserDataCache cache = mock(UserDataCache.class); when(f.user.isCached()).thenReturn(true); @@ -84,13 +79,13 @@ void queuedCachedWriteDoesNotReachSqlOrCreateAnotherQueue() { verifyNoInteractions(f.table, f.timer); } - @Test - void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { + @Test void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { Fixture f = new Fixture(); f.data.setInt("Points", 19, false, true); - ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); - verify(f.timer).execute(task.capture()); - verifyNoInteractions(f.table, f.manager); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(f.timer).execute(task.capture()); + verifyNoInteractions(f.table); + verify(f.manager, never()).onChange(any(), anyString()); when(f.user.getUUID()).thenReturn("current-id"); task.getValue().run(); var order = inOrder(f.table, f.manager); @@ -98,18 +93,18 @@ void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { order.verify(f.manager).onChange(f.user, "Points"); } - @Test - void storageFailureDoesNotClearCacheAndSchedulerRejectionDoesNotWrite() { + @Test void storageFailureDoesNotClearCacheAndSchedulerRejectionDoesNotWrite() { Fixture f = new Fixture(); IllegalStateException failure = new IllegalStateException("delete failed"); doThrow(failure).when(f.table).deletePlayer("initial-id"); assertSame(failure, assertThrows(IllegalStateException.class, f.data::remove)); verify(f.user, never()).clearCache(); - clearInvocations(f.table); + clearInvocations(f.table, f.manager); RejectedExecutionException rejected = new RejectedExecutionException("stopped"); - doThrow(rejected).when(f.timer).execute(any(Runnable.class)); - assertSame(rejected, assertThrows(RejectedExecutionException.class, () -> f.data.setInt("Points", 1, false, true))); - verifyNoInteractions(f.table, f.manager); + doThrow(rejected).when(f.timer).execute(any(Runnable.class)); + assertSame(rejected, assertThrows(RejectedExecutionException.class, () -> f.data.setInt("Points", 1, false, true))); + verifyNoInteractions(f.table); + verify(f.manager, never()).onChange(any(), anyString()); } private static final class Fixture { 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 7288548ddd..6235eb0c0d 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 @@ -11,6 +11,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; import java.util.ArrayList; import java.util.HashMap; @@ -85,6 +86,36 @@ public void setUp() { user.setData(data); // Inject the mocked UserData object } + @Test + void updateNameDoesNotPersistABlankResolvedName() { + AdvancedCoreUser unnamed = new AdvancedCoreUser(plugin, UUID.randomUUID(), null); + unnamed.setData(data); + when(data.hasData()).thenReturn(true); + when(data.getString("PlayerName", UserDataFetchMode.TEMP_ONLY)).thenReturn(""); + when(data.getString("PlayerName", UserDataFetchMode.DEFAULT)).thenReturn("StoredName"); + + unnamed.updateName(false); + + verify(data, never()).getString("PlayerName", UserDataFetchMode.DEFAULT); + verify(data, never()).setString(eq("PlayerName"), any(String.class), eq(true)); + } + + @Test + void replayEntryPointsDeferCompleteSharedStorageWork() { + AdvancedCoreUser replayUser = org.mockito.Mockito.spy(user); + org.mockito.Mockito.doReturn(true).when(replayUser).isOnline(); + when(dataManager.mustDeferSharedStorageAccess()).thenReturn(true); + when(dataManager.deferSharedStorageWork(any(Runnable.class))).thenReturn(true); + + replayUser.checkOfflineRewards(); + replayUser.checkDelayedTimedRewards(); + replayUser.forceRunOfflineRewards(); + + verify(dataManager, org.mockito.Mockito.times(3)).deferSharedStorageWork(any(Runnable.class)); + verify(replayUser, org.mockito.Mockito.times(3)).isOnline(); + verify(data, never()).getStringList(any(String.class), any(UserDataFetchMode.class)); + } + @Test void unclaimedChoiceOccurrenceIsIdempotentButDistinctOccurrencesRemainClaimable() { AtomicReference> stored = new AtomicReference<>(new ArrayList<>()); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java new file mode 100644 index 0000000000..5f4b96f3cd --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java @@ -0,0 +1,151 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doAnswer; + +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.bukkit.Bukkit; +import org.bukkit.Server; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +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.api.user.userstorage.sql.UserTable; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; + +class BukkitUserRuntimeBootstrapTest { + @Test void nativeEnumerationFailureIsNotReportedAsAPartialSuccess() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + doAnswer(invocation -> { + invocation.getArgument(2, java.util.function.Consumer.class) + .accept(new java.sql.SQLException("fixture")); + return null; + }).when(mysql).forEachUser(any(), any(), any()); + + var backend = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(plugin); + IllegalStateException failure = assertThrows(IllegalStateException.class, backend::enumerateUsers); + assertTrue(failure.getCause() instanceof java.sql.SQLException); + } + + @Test void nativeSqliteEnumerationFailureIsNotReportedAsAPartialSuccess() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserTable table = mock(UserTable.class); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + doAnswer(invocation -> { + invocation.getArgument(2, java.util.function.Consumer.class) + .accept(new java.sql.SQLException("fixture")); + return null; + }).when(table).forEachUser(any(), any(), any()); + + var backend = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(plugin); + IllegalStateException failure = assertThrows(IllegalStateException.class, backend::enumerateUsers); + assertTrue(failure.getCause() instanceof java.sql.SQLException); + } + + @Test void sharedAdapterRejectsPrimaryThreadStorageAccess() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + UserDataManager manager = new UserDataManager(plugin); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertThrows(IllegalStateException.class, () -> manager.withSharedSqlBackend(UUID.randomUUID(), + (storage, user) -> user.readRow(storage))); + } + verify(mysql, never()).getExact(anyString()); + } finally { + CountDownLatch retired = new CountDownLatch(1); + assertTrue(manager.closeSharedRuntimeAsync(retired::countDown)); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + manager.getTimer().shutdownNow(); + } + } + + @Test void bindsOnlyAfterExistingMysqlStorageIsAvailableAndDoesNotOwnItsClose() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + when(mysql.getExact(anyString())).thenReturn(new ArrayList<>()); + UserDataManager manager = new UserDataManager(plugin); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + assertTrue(manager.hasSharedRuntime()); + UUID uuid = UUID.randomUUID(); + manager.withSharedSqlBackend(uuid, (storage, user) -> user.readRow(storage)); + verify(mysql).getExact(uuid.toString()); + + CountDownLatch retired = new CountDownLatch(1); + assertTrue(manager.closeSharedRuntimeAsync(retired::countDown)); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + verify(mysql, never()).close(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void refusesToBindBeforeNativeStorageInitialization() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + UserDataManager manager = new UserDataManager(plugin); + try { + assertThrows(IllegalStateException.class, + () -> BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager)); + assertFalse(manager.hasSharedSqlBackend()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void boundBackendKeepsItsInitializedStorageTypeAfterOptionsChange() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + AtomicReference configured = new AtomicReference<>(UserStorage.MYSQL); + when(plugin.getStorageType()).thenAnswer(ignored -> configured.get()); + when(plugin.getMysql()).thenReturn(mysql); + when(mysql.getExact(anyString())).thenReturn(new ArrayList<>()); + UserDataManager manager = new UserDataManager(plugin); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + configured.set(UserStorage.SQLITE); + UUID uuid = UUID.randomUUID(); + + manager.withSharedSqlBackend(uuid, (storage, user) -> { + assertTrue(storage == UserStorage.MYSQL); + return user.readRow(storage); + }); + + verify(mysql).getExact(uuid.toString()); + verify(plugin, never()).getSQLiteUserTable(); + } finally { + CountDownLatch retired = new CountDownLatch(1); + assertTrue(manager.closeSharedRuntimeAsync(retired::countDown)); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java new file mode 100644 index 0000000000..bfc1f6a0f1 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java @@ -0,0 +1,44 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import java.util.logging.Logger; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +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.bukkit.user.runtime.BukkitUserRuntimeBootstrap; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; + +class BukkitUserRuntimeShutdownTest { + @Test void runtimeShutdownWaitsForSharedRetirementBeforeClosingLegacyMysql() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + MySQL mysql = mock(MySQL.class); + UserManager users = mock(UserManager.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getLoadedUserManager()).thenReturn(users); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + new AdvancedCoreRuntime(new BukkitRuntimePlatform(plugin)).shutdown(); + verify(mysql).close(); + } finally { + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java new file mode 100644 index 0000000000..defddcf518 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java @@ -0,0 +1,34 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; + +class SharedBindingAdmissionTest { + @Test + void bindingTransitionBlocksAnUnmappedCacheFromStartingANewLegacyBatch() { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + try { + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + cache.addChange(new UserDataChangeInt("Points", 7), true); + manager.beginSharedBindingTransition(); + try { + assertThrows(IllegalStateException.class, cache::processChanges); + assertTrue(cache.hasChangesToProcess()); + } finally { + manager.endSharedBindingTransition(); + } + } finally { + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java new file mode 100644 index 0000000000..bf7f2eb143 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java @@ -0,0 +1,1017 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.bukkit.Bukkit; +import org.bukkit.Server; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +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; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +@Timeout(15) +class SharedCacheBindingRegressionTest { + @Test void sharedPopulationRetainsPersistedDynamicColumns() throws Exception { + try (Fixture fixture = new Fixture()) { + var data = fixture.plugin.getUserManager().getUser(fixture.uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>(List.of("VoteShopLimitDaily"))); + when(data.getValues()).thenReturn(new HashMap<>(Map.of("VoteShopLimitDaily", new DataValueInt(4)))); + SharedUserDataRuntime runtime = fixture.runtime(); + + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + + assertEquals(4, cache.snapshot().get("VoteShopLimitDaily").getInt()); + runtime.close(); + } + } + + @Test void noDatabaseLookupMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.NO_DB_LOOKUP); } + @Test void cacheOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.CACHE_ONLY); } + @Test void temporaryOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.TEMP_ONLY); } + + @Test void userDataRemovalUsesTheSharedRuntimeExclusiveDeletePath() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + SqlUserBackend backend = mock(SqlUserBackend.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); + when(runtime.isClosed()).thenReturn(false); + when(runtime.backend()).thenReturn(backend); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedRuntime(runtime); + try { + new UserData(user).remove(); + verify(runtime).remove(uuid); + verify(user, never()).clearCache(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void explicitAlternateStoreReadsCannotUseTheSharedCache() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.bindSharedRuntime(runtime); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + UserData data = new UserData(user); + + assertThrows(IllegalStateException.class, + () -> data.getInt(UserStorage.SQLITE, "Points", 0, UserDataFetchMode.CACHE_ONLY)); + assertThrows(IllegalStateException.class, + () -> data.getString(UserStorage.SQLITE, "PlayerName", UserDataFetchMode.CACHE_ONLY)); + verify(user, never()).getCache(); + runtime.close(); + } + } + + @Test void legacyCachePopulationPublishesOnlyAfterSharedAdmission() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + var data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenReturn(new HashMap<>()); + SqlUserBackend backend = mock(SqlUserBackend.class); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + manager.bindSharedSqlBackend(backend, (id, operation) -> { + entered.countDown(); + try { + await(release); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + operation.run(); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var population = worker.submit(() -> manager.cacheUser(uuid, null)); + await(entered); + assertFalse(manager.containsKey(uuid), "a detached snapshot must not be published before admission"); + release.countDown(); + population.get(5, TimeUnit.SECONDS); + assertTrue(manager.containsKey(uuid)); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void preBindingCachePopulationBlocksSharedTransitionAdmission() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + CountDownLatch loading = new CountDownLatch(1), release = new CountDownLatch(1); + var data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenAnswer(call -> { + loading.countDown(); + await(release); + return new HashMap(); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var population = worker.submit(() -> manager.cacheUser(uuid, null)); + await(loading); + assertThrows(IllegalStateException.class, manager::beginSharedBindingTransition); + release.countDown(); + population.get(5, TimeUnit.SECONDS); + assertDoesNotThrow(manager::beginSharedBindingTransition); + manager.endSharedBindingTransition(); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void cachePopulationCannotPublishAfterBackendReplacementStarts() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + CountDownLatch loading = new CountDownLatch(1), release = new CountDownLatch(1); + var data = fixture.plugin.getUserManager().getUser(fixture.uuid, false).getUserData(); + doAnswer(call -> { + loading.countDown(); + try { + await(release); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + return new HashMap(); + }).when(data).getValues(); + MemoryBackend replacement = new MemoryBackend(UserStorage.SQLITE); + var workers = Executors.newFixedThreadPool(2); + try { + var population = workers.submit(() -> fixture.manager.cacheUser(fixture.uuid, null)); + await(loading); + var replacementTask = workers.submit(() -> runtime.replaceBackend(replacement)); + assertThrows(java.util.concurrent.TimeoutException.class, + () -> replacementTask.get(200, TimeUnit.MILLISECONDS)); + release.countDown(); + population.get(5, TimeUnit.SECONDS); + replacementTask.get(5, TimeUnit.SECONDS); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertFalse(fixture.first.isOpen()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void managerClearDoesNotDeadlockWithSameUserPopulation() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writing = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + cache.setSharedStorageWriter(values -> { + writing.countDown(); + try { + await(releaseWrite); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + }); + cache.addChange(new UserDataChangeInt("Points", 1), true); + var workers = Executors.newFixedThreadPool(2); + try { + var clear = workers.submit(fixture.manager::clearCache); + await(writing); + var population = workers.submit(() -> fixture.manager.cacheUser(fixture.uuid, null)); + releaseWrite.countDown(); + clear.get(5, TimeUnit.SECONDS); + population.get(5, TimeUnit.SECONDS); + assertTrue(fixture.manager.containsKey(fixture.uuid)); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + private void cacheMiss(UserDataFetchMode mode) { + SqlUserBackend backend = mock(SqlUserBackend.class); + UserCacheOwner owner = mock(UserCacheOwner.class); + when(backend.isOpen()).thenReturn(true); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + UUID uuid = UUID.randomUUID(); + DataValue fallback = new DataValueInt(17); + assertSame(fallback, runtime.read(uuid, "Points", mode, null, fallback)); + assertSame(fallback, runtime.read(uuid, "Points", mode, new HashMap<>(), fallback)); + verify(backend, never()).user(any(UUID.class)); + verify(owner, never()).populate(any(UUID.class), any()); + verify(owner, never()).queueChange(any(), any(), any()); + verify(owner, never()).flush(any(), any(UserStorage.class), any()); + } + + @Test void noDatabaseModesRetainTemporaryAndUserCachePrecedence() { + SqlUserBackend backend = mock(SqlUserBackend.class); + UserCacheOwner owner = mock(UserCacheOwner.class); + when(backend.isOpen()).thenReturn(true); + UUID uuid = UUID.randomUUID(); + DataValue cached = new DataValueInt(2), temporary = new DataValueInt(3); + when(owner.getIfPresent(uuid, "Points")).thenReturn(cached); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + HashMap temp = new HashMap<>(Map.of("Points", temporary)); + assertSame(temporary, runtime.read(uuid, "Points", UserDataFetchMode.NO_DB_LOOKUP, temp, null)); + assertSame(cached, runtime.read(uuid, "Points", UserDataFetchMode.CACHE_ONLY, temp, null)); + assertSame(temporary, runtime.read(uuid, "Points", UserDataFetchMode.TEMP_ONLY, temp, null)); + verify(backend, never()).user(any(UUID.class)); + } + + @Test void managerGetCacheAfterRuntimeStartupUsesTheSelectedWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 7), true); + fixture.tasks.get(0).run(); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertFalse(cache.hasChangesToProcess()); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void completedBatchDoesNotOverwriteANewerQueuedCacheValue() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + var worker = Executors.newSingleThreadExecutor(); + try { + var firstBatch = worker.submit(fixture.tasks.get(0)); + await(writeStarted); + cache.addChange(new UserDataChangeInt("Points", 9), true); + assertEquals(9, cache.getCache().get("Points").getInt()); + releaseWrite.countDown(); + firstBatch.get(5, TimeUnit.SECONDS); + assertEquals(9, cache.getCache().get("Points").getInt(), + "the older completed batch must not replace the newer queued value"); + fixture.first.blockWrites(null, null); + fixture.tasks.get(1).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void immediateWriteIsPersistedAfterAnOlderQueuedBatch() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + var workers = Executors.newFixedThreadPool(2); + try { + var queued = workers.submit(fixture.tasks.get(0)); + await(writeStarted); + var immediate = workers.submit(() -> data.setInt(UserStorage.MYSQL, "Points", 9, false, false)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (cache.getCache().get("Points").getInt() != 9 && System.nanoTime() < deadline) { + Thread.yield(); + } + assertEquals(9, cache.getCache().get("Points").getInt()); + releaseWrite.countDown(); + queued.get(5, TimeUnit.SECONDS); + immediate.get(5, TimeUnit.SECONDS); + assertEquals(9, fixture.first.points(fixture.uuid), + "the immediate write must reach storage after the older queued batch"); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void immediateSharedWriteReportsOneChangeAfterPersistence() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + var userManager = fixture.plugin.getUserManager(); + clearInvocations(userManager); + + new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, false, false); + + assertEquals(9, fixture.first.points(fixture.uuid)); + verify(userManager, times(1)).onChange(eq(user), any(String[].class)); + runtime.close(); + } + } + + @Test void primaryThreadImmediateSharedWriteDefersWithoutLosingOrdering() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + var userManager = fixture.plugin.getUserManager(); + clearInvocations(userManager); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, false); + + UserData data = new UserData(user); + assertDoesNotThrow(() -> data.setInt(UserStorage.MYSQL, "Points", 9, false, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + assertEquals(9, cache.snapshot().get("Points").getInt(), + "the setter must publish read-after-write state before returning"); + data.setInt(UserStorage.MYSQL, "Points", + data.getInt(UserStorage.MYSQL, "Points", 0, UserDataFetchMode.DEFAULT) + 1, false, false); + assertEquals(10, cache.snapshot().get("Points").getInt(), + "same-tick read-modify-write must observe the preceding setter"); + fixture.tasks.get(fixture.tasks.size() - 1).run(); + assertEquals(10, fixture.first.points(fixture.uuid)); + var callback = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(fixture.plugin.getBukkitScheduler()).runTask(eq(fixture.plugin), callback.capture()); + verify(userManager, never()).onChange(any(), any(String[].class)); + callback.getValue().run(); + verify(userManager, times(1)).onChange(eq(user), any(String[].class)); + runtime.close(); + } + } + + @Test void immediateWriteToAnotherStoreDoesNotUseTheSharedWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + + assertThrows(IllegalStateException.class, + () -> new UserData(user).setInt(UserStorage.SQLITE, "Points", 9, false, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid), + "an explicit alternate-store write must not be redirected to the shared backend"); + assertFalse(cache.getCache().containsKey("Points"), + "a rejected cross-store write must not leave an unpersisted shared-cache value behind"); + + assertThrows(IllegalStateException.class, + () -> new UserData(user).setInt(UserStorage.SQLITE, "Queued", 11, true, false)); + assertFalse(cache.hasChangesToProcess(), + "a queued cross-store write must be rejected before it reaches the active shared writer"); + runtime.close(); + } + } + + @Test void primaryThreadPendingPopulationDefersMutationIntoTheBoundGeneration() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenAnswer(ignored -> fixture.manager.isCached(fixture.uuid)); + when(user.getCache()).thenAnswer(ignored -> fixture.manager.getCache(fixture.uuid)); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, true, true, false); + + assertDoesNotThrow(() -> new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, true, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid), "the primary thread must not write SQL"); + assertEquals(2, fixture.tasks.size(), "population must be queued before the mutation"); + assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).snapshot().containsKey("Points"), + "an unbound placeholder must not accept a gate-bypassing mutation"); + + fixture.tasks.get(0).run(); + assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).snapshot().containsKey("Points")); + fixture.tasks.get(1).run(); + assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), + "the admitted worker mutation must publish into the populated cache"); + assertEquals(3, fixture.tasks.size(), "the queued mutation must schedule its shared flush"); + fixture.tasks.get(2).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void implicitFacadeUsesTheRuntimeStorageAfterOptionsReload() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.getCache()).thenReturn(cache); + + UserData data = new UserData(user); + data.setInt("Points", 14, false); + + assertEquals(UserStorage.MYSQL, + fixture.manager.effectiveStorageType(fixture.plugin.getStorageType())); + assertEquals(14, fixture.first.points(fixture.uuid)); + assertEquals(14, data.getInt("Points", UserDataFetchMode.DEFAULT)); + assertEquals(14, data.getValues().get("Points").getInt()); + runtime.close(); + } + } + + @Test void primaryThreadBulkSetterSnapshotsAndPublishesBeforeReturning() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.getCache()).thenReturn(cache); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + HashMap submitted = new HashMap<>(Map.of("Points", new DataValueInt(18))); + + new UserData(user).setValues(submitted); + + assertEquals(18, cache.snapshot().get("Points").getInt()); + submitted.clear(); + fixture.tasks.get(fixture.tasks.size() - 1).run(); + assertEquals(18, fixture.first.points(fixture.uuid)); + runtime.close(); + } + } + + @Test void workerMutationCannotBypassAPendingPrimaryThreadPopulation() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.getCache()).thenAnswer(ignored -> fixture.manager.getCache(fixture.uuid)); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, false); + + fixture.manager.getCache(fixture.uuid); + new UserData(user).setInt(UserStorage.MYSQL, "Points", 12, true, false); + + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + fixture.tasks.get(0).run(); + assertEquals(12, fixture.first.points(fixture.uuid)); + assertEquals(12, fixture.manager.getUserDataCache().get(fixture.uuid).snapshot().get("Points").getInt()); + runtime.close(); + } + } + + @Test void completedBatchReconcilesATemporaryCacheSnapshotWithoutPersistingTheSnapshot() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + data.updateTempCacheWithColumns(new ArrayList<>(List.of(new Column("Points", new DataValueInt(3))))); + var worker = Executors.newSingleThreadExecutor(); + try { + var firstBatch = worker.submit(fixture.tasks.get(0)); + await(writeStarted); + + data.updateCacheWithTemp(); + assertEquals(3, cache.getCache().get("Points").getInt(), + "the temporary storage snapshot is visible while the earlier write is in flight"); + + releaseWrite.countDown(); + firstBatch.get(5, TimeUnit.SECONDS); + + assertEquals(7, fixture.first.points(fixture.uuid)); + assertEquals(7, cache.getCache().get("Points").getInt(), + "the completed queued write must reconcile the stale temporary snapshot"); + assertFalse(cache.hasChangesToProcess(), + "a read snapshot must not become an automatic persistence request"); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void temporarySnapshotCannotEraseANewerQueuedMutationFence() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + data.updateTempCacheWithColumns(new ArrayList<>(List.of(new Column("Points", new DataValueInt(3))))); + var worker = Executors.newSingleThreadExecutor(); + try { + var firstBatch = worker.submit(fixture.tasks.get(0)); + await(writeStarted); + cache.addChange(new UserDataChangeInt("Points", 9), true); + data.updateCacheWithTemp(); + releaseWrite.countDown(); + firstBatch.get(5, TimeUnit.SECONDS); + + assertEquals(9, cache.getCache().get("Points").getInt(), + "the queued mutation must remain visible after the older batch completes"); + fixture.first.blockWrites(null, null); + fixture.tasks.get(1).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void joinStyleCacheCreationAfterStartupUsesTheSelectedWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.cacheUser(fixture.uuid, null); + fixture.manager.getCache(fixture.uuid).addChange(new UserDataChangeInt("Points", 8), true); + fixture.tasks.get(0).run(); + assertEquals(8, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void directCacheInsertionAfterStartupAlsoGetsTheGateAndWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + cache.addChange(new UserDataChangeInt("Points", 9), true); + cache.processChanges(); + assertEquals(9, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void aWriterWithoutALifecycleGateDoesNotPreventSharedAttachment() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + cache.setSharedStorageWriter(values -> fail("ungated writer was reused")); + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + cache.addChange(new UserDataChangeInt("Points", 16), true); + runtime.close(); + assertEquals(16, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + } + } + + @Test void anAlreadyConstructedCacheCannotBypassBindingWhenInsertedLater() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 10), true); + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + fixture.tasks.get(0).run(); + assertEquals(10, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void cachesCreatedAfterReplacementDoNotReuseTheOldWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache old = fixture.manager.getCache(fixture.uuid); + old.addChange(new UserDataChangeInt("Points", 11), true); + MemoryBackend replacement = new MemoryBackend(UserStorage.SQLITE); + runtime.replaceBackend(replacement); + assertEquals(11, fixture.first.points(fixture.uuid)); + UserDataCache fresh = fixture.manager.getCache(fixture.uuid); + assertNotSame(old, fresh); + fresh.addChange(new UserDataChangeInt("Points", 12), true); + fresh.processChanges(); + assertEquals(12, replacement.points(fixture.uuid)); + assertEquals(11, fixture.first.points(fixture.uuid)); + assertThrows(IllegalStateException.class, () -> old.addChange(new UserDataChangeInt("Points", 99), true)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void newlyInsertedCacheCannotQueueAfterRetirementStarts() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + List worker = new ArrayList<>(); + CompletableFuture closing = runtime.closeAsync(worker::add).toCompletableFuture(); + UserDataCache late = new UserDataCache(fixture.manager, fixture.uuid); + fixture.manager.getUserDataCache().put(fixture.uuid, late); + assertThrows(IllegalStateException.class, () -> late.addChange(new UserDataChangeInt("Points", 99), true)); + worker.get(0).run(); + closing.join(); + assertThrows(IllegalStateException.class, () -> late.addChange(new UserDataChangeInt("Points", 99), true)); + } + } + + @Test void legacyMonitorHeldDuringLazyAttachmentFailsWithoutDeadlocking() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 15), true); + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + synchronized (cache) { assertThrows(IllegalStateException.class, cache::processChanges); } + assertTrue(cache.hasChangesToProcess()); + runtime.close(); + assertEquals(15, fixture.first.points(fixture.uuid)); + } + } + + @Test void aFailedAttachmentLeavesTheSameOwnerReusable() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException unavailable = new IllegalStateException("registration unavailable"); + doThrow(unavailable).doCallRealMethod().when(fixture.manager).bindSharedCacheInitializer(any()); + assertSame(unavailable, assertThrows(IllegalStateException.class, fixture::runtime)); + SharedUserDataRuntime retry = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 13), true); + retry.close(); + assertEquals(13, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + } + } + + @Test void activeLegacyBatchRejectsAttachmentWithoutPublishingAndRetrySucceeds() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 1), true); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var legacyData = cache.getUser().getUserData(); + doAnswer(call -> { entered.countDown(); await(release); return null; }).when(legacyData).setValues(any(HashMap.class)); + var legacyWorker = Executors.newSingleThreadExecutor(); + try { + var legacy = legacyWorker.submit(cache::processChanges); + await(entered); + assertThrows(IllegalStateException.class, fixture::runtime); + release.countDown(); + legacy.get(5, TimeUnit.SECONDS); + SharedUserDataRuntime runtime = fixture.runtime(); + cache.addChange(new UserDataChangeInt("Points", 14), true); + runtime.close(); + assertEquals(14, fixture.first.points(fixture.uuid)); + } finally { + release.countDown(); + legacyWorker.shutdownNow(); + assertTrue(legacyWorker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + } + + @Test void legacyRemovalUsesTheExclusiveSharedAdmission() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + int[] exclusiveCalls = { 0 }; + manager.bindSharedSqlBackend(backend, + (id, operation) -> fail("legacy removal must not use shared read admission"), + (id, operation) -> { exclusiveCalls[0]++; operation.run(); }); + + manager.removeCache(uuid, null); + + assertEquals(1, exclusiveCalls[0]); + assertFalse(manager.containsKey(uuid)); + } + + @Test void legacyRemovalRetiresTheSharedCacheBeforeDetachingIt() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + + fixture.manager.removeCache(fixture.uuid, null); + + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertThrows(IllegalStateException.class, + () -> cache.addChange(new UserDataChangeInt("Points", 2), true)); + runtime.close(); + } + } + + @Test void managerDrivenRemovalEvictsTheOwnersPerUserGate() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getCache(fixture.uuid); + Field gatesField = BukkitUserCacheOwner.class.getDeclaredField("cacheGates"); + gatesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map gates = (Map) gatesField.get(fixture.owner); + assertTrue(gates.containsKey(fixture.uuid)); + + fixture.manager.removeCache(fixture.uuid, null); + + assertFalse(gates.containsKey(fixture.uuid)); + runtime.close(); + } + } + + @Test void managerWideClearDoesNotHoldMapAdmissionWhileSharedFlushWaits() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 18), true); + var workers = Executors.newFixedThreadPool(2); + try { + var clearing = workers.submit(fixture.manager::clearCache); + await(writeStarted); + // A shared cache flush is in progress. A population already admitted by + // the runtime must still be able to take the cache-map read admission. + assertTrue(workers.submit(() -> fixture.manager.withCacheMapReadAdmission(() -> Boolean.TRUE)) + .get(200, TimeUnit.MILLISECONDS)); + releaseWrite.countDown(); + clearing.get(5, TimeUnit.SECONDS); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void managerWideClearExcludesQueuedWritesUntilTheCacheIsDetached() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 18), true); + var workers = Executors.newFixedThreadPool(2); + try { + var clearing = workers.submit(fixture.manager::clearCache); + await(writeStarted); + var concurrentWrite = workers.submit(() -> + cache.addChange(new UserDataChangeInt("Points", 19), true)); + assertThrows(java.util.concurrent.TimeoutException.class, + () -> concurrentWrite.get(200, TimeUnit.MILLISECONDS), + "a write must wait for the exclusive retirement instead of being silently dropped"); + releaseWrite.countDown(); + clearing.get(5, TimeUnit.SECONDS); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> concurrentWrite.get(5, TimeUnit.SECONDS)); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertEquals(18, fixture.first.points(fixture.uuid)); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void failedManagerWideClearReopensEveryStillMappedCache() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + try { + UserDataCache first = failingCache(manager, UUID.randomUUID()); + UserDataCache second = failingCache(manager, UUID.randomUUID()); + manager.getUserDataCache().put(first.getUuid(), first); + manager.getUserDataCache().put(second.getUuid(), second); + manager.bindSharedSqlBackend(mock(SqlUserBackend.class), (uuid, operation) -> operation.run()); + + assertThrows(IllegalStateException.class, manager::clearCache); + + first.setSharedStorageWriter(values -> {}); + second.setSharedStorageWriter(values -> {}); + first.addChange(new UserDataChangeInt("Retry", 1), true); + second.addChange(new UserDataChangeInt("Retry", 1), true); + assertTrue(first.getCache().containsKey("Retry")); + assertTrue(second.getCache().containsKey("Retry")); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void failedPerUserRemovalReopensTheMappedCache() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + try { + UserDataCache cache = failingCache(manager, UUID.randomUUID()); + manager.getUserDataCache().put(cache.getUuid(), cache); + manager.bindSharedSqlBackend(mock(SqlUserBackend.class), (uuid, operation) -> operation.run()); + + assertThrows(IllegalStateException.class, () -> manager.removeCache(cache.getUuid(), null)); + + cache.setSharedStorageWriter(values -> {}); + cache.addChange(new UserDataChangeInt("Retry", 1), true); + assertTrue(cache.getCache().containsKey("Retry")); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void refreshFlushCallbackCanRemoveTheSameUserAfterSharedAdmission() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 21), true); + var userManager = fixture.plugin.getUserManager(); + doAnswer(call -> { + fixture.manager.removeCache(fixture.uuid, null); + return null; + }).when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + + assertDoesNotThrow(() -> fixture.manager.cacheUser(fixture.uuid, null)); + + assertEquals(21, fixture.first.points(fixture.uuid)); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + Field completedField = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completedField.setAccessible(true); + @SuppressWarnings("unchecked") + Set completed = (Set) completedField.get(fixture.manager); + assertFalse(completed.contains(fixture.uuid), + "a callback-driven removal must not be followed by a stale completed-population marker"); + runtime.close(); + } + } + + private UserDataCache failingCache(UserDataManager manager, UUID uuid) { + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(1)))); + cache.setSharedStorageWriter(values -> { throw new IllegalStateException("write failed"); }); + cache.addChange(new UserDataChangeInt("Points", 2), true); + return cache; + } + + private static void await(CountDownLatch latch) throws InterruptedException { assertTrue(latch.await(5, TimeUnit.SECONDS)); } + + private static final class Fixture implements AutoCloseable { + final UUID uuid = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + final UserDataManager manager; + final BukkitUserCacheOwner owner; + final MemoryBackend first = new MemoryBackend(UserStorage.MYSQL); + final List tasks = new CopyOnWriteArrayList<>(); + final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + + Fixture() throws Exception { + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + manager = spy(new UserDataManager(plugin)); + manager.getTimer().shutdownNow(); + Field field = UserDataManager.class.getDeclaredField("timer"); + field.setAccessible(true); + field.set(manager, timer); + when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return mock(ScheduledFuture.class); + }); + doAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return null; + }).when(timer).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(timer); + var data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenAnswer(ignored -> new ArrayList()); + when(data.getValues()).thenAnswer(ignored -> new HashMap()); + owner = new BukkitUserCacheOwner(manager); + } + + SharedUserDataRuntime runtime() { return new SharedUserDataRuntime(first, owner); } + void assertNoLegacyWrites() { verify(plugin.getUserManager().getUser(uuid, false).getUserData(), never()).setValues(any(HashMap.class)); } + public void close() { manager.getTimer().shutdownNow(); } + } + + private static final class MemoryBackend implements SqlUserBackend { + final UserStorage type; + final Map> rows = new ConcurrentHashMap<>(); + volatile CountDownLatch writeStarted; + volatile CountDownLatch releaseWrite; + boolean open = true; + MemoryBackend(UserStorage type) { this.type = type; } + void blockWrites(CountDownLatch started, CountDownLatch release) { + writeStarted = started; + releaseWrite = release; + } + int points(UUID uuid) { return rows.get(uuid).get("Points").getInt(); } + public UserStorage storageType() { return type; } + public boolean isOpen() { return open; } + public void close() { open = false; } + public List enumerateUsers() { return new ArrayList<>(rows.keySet()); } + public SqlUserStorage user(UUID uuid) { + if (!open) throw new IllegalStateException("closed backend"); + return new SqlUserStorage() { + public List readRow(UserStorage requested) { + List columns = new ArrayList<>(); + rows.getOrDefault(uuid, new HashMap<>()).forEach((key, value) -> columns.add(new Column(key, value))); + return columns; + } + public boolean contains(UserStorage requested) { return rows.containsKey(uuid); } + public void delete(UserStorage requested) { rows.remove(uuid); } + public void write(UserStorage requested, String key, DataValue value) { writeValues(requested, new HashMap<>(Map.of(key, value))); } + public void writeValues(UserStorage requested, HashMap values) { + assertEquals(type, requested); + if (!open) throw new IllegalStateException("closed backend"); + CountDownLatch started = writeStarted; + CountDownLatch release = releaseWrite; + if (started != null && release != null) { + started.countDown(); + try { await(release); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } + rows.computeIfAbsent(uuid, ignored -> new HashMap<>()).putAll(values); + } + }; + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java new file mode 100644 index 0000000000..93eafb4926 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -0,0 +1,481 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; +import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.PlayerManager; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +class SharedCacheCleanupPrimaryThreadTest { + @Test + void uuidUserConstructionDefersNameLookupOnPrimaryThread() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + UUID uuid = UUID.randomUUID(); + try (var bukkit = mockStatic(Bukkit.class); var players = mockStatic(PlayerManager.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + PlayerManager playerManager = mock(PlayerManager.class); + players.when(PlayerManager::getInstance).thenReturn(playerManager); + when(playerManager.getPlayerName(any(AdvancedCoreUser.class), eq(uuid.toString()), eq(false))) + .thenReturn("StoredName"); + + AdvancedCoreUser user = assertDoesNotThrow(() -> new AdvancedCoreUser(plugin, uuid)); + assertEquals("", user.getPlayerName()); + verify(playerManager, never()).getPlayerName(any(), any(), eq(false)); + ArgumentCaptor storageTask = ArgumentCaptor.forClass(Runnable.class); + verify(worker).execute(storageTask.capture()); + storageTask.getValue().run(); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), callback.capture()); + callback.getValue().run(); + assertEquals("StoredName", user.getPlayerName()); + } + } + + @Test + void primaryThreadPersistedBulkReadsRequireWorkerDeferral() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + UUID uuid = UUID.randomUUID(); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(7)))); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + IllegalStateException keysFailure = assertThrows(IllegalStateException.class, data::getKeys); + IllegalStateException valuesFailure = assertThrows(IllegalStateException.class, data::getValues); + assertTrue(data.hasData()); + IllegalStateException intFailure = assertThrows(IllegalStateException.class, + () -> data.getInt(UserStorage.MYSQL, "Points", -1, + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE)); + IllegalStateException stringFailure = assertThrows(IllegalStateException.class, + () -> data.getString(UserStorage.MYSQL, "PlayerName", + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE)); + assertTrue(intFailure.getMessage().contains("defer")); + assertTrue(stringFailure.getMessage().contains("defer")); + assertTrue(keysFailure.getMessage().contains("defer")); + assertTrue(valuesFailure.getMessage().contains("defer")); + verify(backend, never()).user(any(UUID.class)); + } + manager.getTimer().shutdownNow(); + } + + @Test + void primaryThreadNameUpdateDefersExistenceCheckBeforeReadingOrWriting() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserData data = mock(UserData.class); + Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + Field fetchMode = AdvancedCoreUser.class.getDeclaredField("userDataFetchMode"); + fetchMode.setAccessible(true); + fetchMode.set(user, com.bencodez.advancedcore.api.user.UserDataFetchMode.DEFAULT); + when(user.getPlugin()).thenReturn(plugin); + when(user.getData()).thenReturn(data); + when(user.getPlayerName()).thenReturn("CurrentName"); + when(data.hasData()).thenReturn(true); + when(data.getString(eq("PlayerName"), any())).thenReturn("OldName"); + doCallRealMethod().when(user).updateName(false); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + ArgumentCaptor storageTask = ArgumentCaptor.forClass(Runnable.class); + assertDoesNotThrow(() -> user.updateName(false)); + verify(worker).execute(storageTask.capture()); + verify(data, never()).hasData(); + verify(data, never()).getString(any(), any()); + storageTask.getValue().run(); + verify(data).hasData(); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getBukkitScheduler()).runTask(eq(plugin), callback.capture()); + callback.getValue().run(); + verify(data).getString("PlayerName", com.bencodez.advancedcore.api.user.UserDataFetchMode.DEFAULT); + verify(data).setString("PlayerName", "CurrentName", true); + } + } + + @Test + void primaryThreadStorageResultIsReadOnTheWorkerAndDeliveredBackToBukkit() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + Player callbackOwner = mock(Player.class); + AtomicBoolean read = new AtomicBoolean(); + AtomicBoolean delivered = new AtomicBoolean(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false, true, false); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + assertTrue(manager.deferSharedStorageResult(() -> { + read.set(true); + return "row"; + }, value -> { + assertEquals("row", value); + delivered.set(true); + }, failure -> { throw new AssertionError(failure); }, callbackOwner)); + verify(worker).execute(task.capture()); + assertFalse(read.get()); + task.getValue().run(); + assertTrue(read.get()); + assertFalse(delivered.get()); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), callback.capture(), same(callbackOwner)); + callback.getValue().run(); + assertTrue(delivered.get()); + + AtomicBoolean failed = new AtomicBoolean(); + assertTrue(manager.deferSharedStorageResult(() -> { + throw new IllegalStateException("read failed"); + }, value -> fail("unexpected success"), failure -> failed.set(true), callbackOwner)); + ArgumentCaptor failedTask = ArgumentCaptor.forClass(Runnable.class); + verify(worker, times(2)).execute(failedTask.capture()); + failedTask.getAllValues().get(1).run(); + ArgumentCaptor failedCallback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler, times(2)).runTask(eq(plugin), failedCallback.capture(), same(callbackOwner)); + failedCallback.getAllValues().get(1).run(); + assertTrue(failed.get()); + } + manager.getTimer().shutdownNow(); + } + + @Test + void acceptedPrimaryThreadCleanupFailureIsLoggedAndRetained() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + RuntimeException failure = new IllegalStateException("flush failed"); + org.mockito.Mockito.doThrow(failure).when(cache).clearCache(); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.clearCache(); + verify(worker).execute(task.capture()); + assertSame(failure, assertThrows(RuntimeException.class, task.getValue()::run)); + assertSame(failure, manager.getLastDeferredStorageFailure()); + verify(logger).log(Level.SEVERE, "Deferred user-cache cleanup failed", failure); + assertTrue(manager.getUserDataCache().containsKey(uuid)); + } + } + + @Test + void failedPrimaryThreadPopulationIsRetainedBeforeNotificationDelivery() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("storage unavailable"); + when(plugin.getUserManager().getDataManager()).thenReturn(manager); + when(plugin.getUserManager().getUser(uuid, false).getUserData().getKeys()).thenThrow(failure); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + + manager.cacheUser(uuid, null); + verify(worker).execute(task.capture()); + task.getValue().run(); + + assertSame(failure, manager.getLastDeferredStorageFailure()); + } + } + + @Test + void synchronousPopulationRethrowsStorageFailure() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("storage unavailable"); + when(plugin.getUserManager().getUser(uuid, false).getUserData().getKeys()).thenThrow(failure); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + try { + assertSame(failure, assertThrows(IllegalStateException.class, + () -> manager.cacheUser(uuid, null))); + assertFalse(manager.containsKey(uuid)); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void primaryThreadCachePopulationIsQueuedAndGetCacheReturnsAPopulationPlaceholder() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> { + throw new AssertionError("primary thread must not admit cache storage work"); + }); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertDoesNotThrow(() -> manager.cacheUser(uuid, null)); + verify(worker).execute(any(Runnable.class)); + assertNotNull(manager.getCache(uuid)); + verify(worker, times(1)).execute(any(Runnable.class)); + } + } + + @Test + void removingCompletedPlaceholderAllowsAReplacementPopulation() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + Field completed = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completed.setAccessible(true); + @SuppressWarnings("unchecked") + Set completedPopulations = (Set) completed.get(manager); + completedPopulations.add(uuid); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.removeCache(uuid, null); + verify(worker).execute(task.capture()); + task.getValue().run(); + assertFalse(completedPopulations.contains(uuid)); + assertNotNull(manager.getCache(uuid)); + verify(worker, times(2)).execute(any(Runnable.class)); + } + } + + @Test + void runtimeOwnerRetirementClearsPopulationMarkersAndFencesAnOldWorker() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + Field populations = UserDataManager.class.getDeclaredField("sharedCachePopulations"); + populations.setAccessible(true); + Field completed = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completed.setAccessible(true); + @SuppressWarnings("unchecked") + Set inFlight = (Set) populations.get(manager); + @SuppressWarnings("unchecked") + Set completedPopulations = (Set) completed.get(manager); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.cacheUser(uuid, null); + verify(worker).execute(task.capture()); + assertTrue(inFlight.contains(uuid)); + + new BukkitUserCacheOwner(manager).clearAfterFlush(); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + assertFalse(inFlight.contains(uuid)); + assertFalse(completedPopulations.contains(uuid)); + + task.getValue().run(); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + assertFalse(completedPopulations.contains(uuid), + "a retired population must not republish completion for a replacement cache"); + verify(cache).retireAfterSharedFlush(); + verifyNoMoreInteractions(cache); + } + } + + @Test + void rejectedPrimaryThreadCleanupIsReportedWithoutRunningStorageWork() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + RejectedExecutionException rejection = new RejectedExecutionException("stopped"); + org.mockito.Mockito.doThrow(rejection).when(worker).execute(any(Runnable.class)); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertSame(rejection, assertThrows(RejectedExecutionException.class, manager::clearCache)); + verify(cache, never()).clearCache(); + assertTrue(manager.getUserDataCache().containsKey(uuid)); + } + } + + @Test + void sharedCacheClearMovesTheWholeFlushAndRemovalSequenceOffThePrimaryThread() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.clearCache(); + verify(worker).execute(task.capture()); + verify(cache, never()).clearCache(); + assertTrue(manager.getUserDataCache().containsKey(uuid)); + task.getValue().run(); + verify(cache).clearCache(); + verify(cache).dump(); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java new file mode 100644 index 0000000000..31c0fd8c4a --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java @@ -0,0 +1,231 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +/** Real cache/queue and runtime; controlled in-memory provider, no live server/database. */ +@Timeout(15) +class SharedCachePopulationRaceTest { + @Test void pendingWriteWinsOverAnOlderDatabaseSnapshot() throws Exception { queuedWrite(false); } + @Test void writeFlushedBeforePublicationStillWinsOverAnOlderSnapshot() throws Exception { queuedWrite(true); } + + private void queuedWrite(boolean flushFirst) throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + // This must finish while the storage load is still held on another thread. + fixture.runtime.queueChange(fixture.uuid, "Points", new DataValueInt(7)); + if (flushFirst) { + fixture.runtime.flush(fixture.uuid); + assertFalse(fixture.cache.hasChangesToProcess()); + assertEquals(7, fixture.backend.rows.get("Points").getInt()); + } + fixture.backend.releaseRead.countDown(); + HashMap published = population.get(5, TimeUnit.SECONDS); + assertEquals(7, published.get("Points").getInt()); + assertEquals(2, published.get("Other").getInt(), "unmodified fields still refresh from storage"); + assertEquals(7, fixture.cached("Points")); + assertEquals(2, fixture.cached("Other")); + fixture.runtime.flush(fixture.uuid); + assertEquals(7, fixture.backend.rows.get("Points").getInt()); + assertEquals(7, fixture.cached("Points")); + fixture.noLegacyWrites(); + } + } + + @Test void memoryOnlyMutationDuringALoadIsNotDiscarded() throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + fixture.cache.addChange(new UserDataChangeInt("Points", 8), false); + fixture.backend.releaseRead.countDown(); + assertEquals(8, population.get(5, TimeUnit.SECONDS).get("Points").getInt()); + assertEquals(8, fixture.cached("Points")); + assertFalse(fixture.cache.hasChangesToProcess()); + assertEquals(1, fixture.backend.rows.get("Points").getInt()); + } + } + + @Test void aLaterFullCacheRefreshWinsOverAnOlderLoad() throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + fixture.cache.updateCache(values(9, 10)); + fixture.backend.releaseRead.countDown(); + assertEquals(9, population.get(5, TimeUnit.SECONDS).get("Points").getInt()); + assertEquals(9, fixture.cached("Points")); + assertEquals(10, fixture.cached("Other")); + } + } + + @Test void aReplacedCacheDoesNotReceiveAnOlderInstancesLoad() throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + UserDataCache replacement = new UserDataCache(fixture.manager, fixture.uuid); + replacement.updateCache(values(11, 12)); + fixture.caches.put(fixture.uuid, replacement); + fixture.backend.releaseRead.countDown(); + assertInstanceOf(IllegalStateException.class, + assertThrows(ExecutionException.class, () -> population.get(5, TimeUnit.SECONDS)).getCause()); + assertSame(replacement, fixture.caches.get(fixture.uuid)); + assertEquals(11, fixture.cached("Points")); + assertEquals(12, fixture.cached("Other")); + } + } + + @Test void failedPopulationRetainsQueuedChangesAndCanBeRetried() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("storage read failed"); + fixture.backend.readFailure = failure; + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + fixture.runtime.queueChange(fixture.uuid, "Points", new DataValueInt(13)); + fixture.backend.releaseRead.countDown(); + assertSame(failure, + assertThrows(ExecutionException.class, () -> population.get(5, TimeUnit.SECONDS)).getCause()); + assertEquals(13, fixture.cached("Points")); + assertTrue(fixture.cache.hasChangesToProcess()); + fixture.backend.readFailure = null; + assertEquals(13, fixture.runtime.populate(fixture.uuid).get("Points").getInt()); + assertEquals(13, fixture.backend.rows.get("Points").getInt()); + assertFalse(fixture.cache.hasChangesToProcess()); + } + } + + @Test void twoOverlappingPopulationsCannotPublishInReverseOrder() throws Exception { + try (Fixture fixture = new Fixture()) { + var first = fixture.owner.beginPopulation(fixture.uuid); + var second = fixture.owner.beginPopulation(fixture.uuid); + fixture.owner.completePopulation(fixture.uuid, values(14, 15), second); + var result = fixture.owner.completePopulation(fixture.uuid, values(1, 2), first); + assertEquals(14, result.get("Points").getInt()); + assertEquals(15, fixture.cached("Other")); + } + } + + @Test void populationTokenCannotBeAppliedToAnotherUser() throws Exception { + try (Fixture fixture = new Fixture()) { + var token = fixture.owner.beginPopulation(fixture.uuid); + assertThrows(IllegalArgumentException.class, () -> + fixture.owner.completePopulation(UUID.randomUUID(), values(99, 99), token)); + assertEquals(1, fixture.cached("Points")); + } + } + + private static HashMap values(int points, int other) { + return new HashMap<>(Map.of("Points", new DataValueInt(points), "Other", new DataValueInt(other))); + } + + private static void await(CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(5, TimeUnit.SECONDS), "storage snapshot did not reach the expected boundary"); + } + + private static final class Fixture implements AutoCloseable { + final UUID uuid = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + final UserDataManager manager = mock(UserDataManager.class, RETURNS_DEEP_STUBS); + final ConcurrentHashMap caches = new ConcurrentHashMap<>(); + final UserDataCache cache = new UserDataCache(manager, uuid); + final MemoryBackend backend = new MemoryBackend(uuid); + final BukkitUserCacheOwner owner; + final SharedUserDataRuntime runtime; + final ExecutorService worker = Executors.newSingleThreadExecutor(); + + Fixture() { + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getUserDataCache()).thenReturn(caches); + when(manager.isCached(any(UUID.class))).thenAnswer(call -> { + UserDataCache current = caches.get(call.getArgument(0, UUID.class)); + return current != null && current.hasCache(); + }); + cache.updateCache(values(1, 1)); + caches.put(uuid, cache); + owner = new BukkitUserCacheOwner(manager); + runtime = new SharedUserDataRuntime(backend, owner); + } + int cached(String key) { + return runtime.read(uuid, key, UserDataFetchMode.CACHE_ONLY, null, null).getInt(); + } + void noLegacyWrites() { + verify(plugin.getUserManager().getUser(uuid, false).getUserData(), never()).setValues(any(HashMap.class)); + } + public void close() throws Exception { + backend.releaseRead.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + runtime.close(); + } + } + + private static final class MemoryBackend implements SqlUserBackend { + final UUID uuid; + final Map rows = new ConcurrentHashMap<>(values(1, 2)); + final CountDownLatch snapshotRead = new CountDownLatch(1), releaseRead = new CountDownLatch(1); + final AtomicBoolean holdFirstRead = new AtomicBoolean(true); + volatile RuntimeException readFailure; + boolean open = true; + MemoryBackend(UUID uuid) { this.uuid = uuid; } + public UserStorage storageType() { return UserStorage.SQLITE; } + public boolean isOpen() { return open; } + public void close() { open = false; } + public List enumerateUsers() { return List.of(uuid); } + public SqlUserStorage user(UUID id) { + assertEquals(uuid, id); + return new SqlUserStorage() { + public List readRow(UserStorage type) { + List snapshot = new ArrayList<>(); + rows.forEach((key, value) -> snapshot.add(new Column(key, value))); + if (holdFirstRead.compareAndSet(true, false)) { + snapshotRead.countDown(); + try { await(releaseRead); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } + if (readFailure != null) throw readFailure; + return snapshot; + } + public boolean contains(UserStorage type) { return !rows.isEmpty(); } + public void delete(UserStorage type) { rows.clear(); } + public void write(UserStorage type, String key, DataValue value) { rows.put(key, value); } + public void writeValues(UserStorage type, HashMap values) { + assertTrue(open, "write after provider close"); + rows.putAll(values); + } + }; + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePrimaryThreadTest.java new file mode 100644 index 0000000000..fe5243640d --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePrimaryThreadTest.java @@ -0,0 +1,86 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +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; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +class SharedCachePrimaryThreadTest { + @Test + void cachedReadsAndQueuedMutationsRemainAllowedOnThePrimaryThread() { + UUID cached = UUID.randomUUID(); + UUID uncached = UUID.randomUUID(); + MemoryBackend backend = new MemoryBackend(); + MemoryCache cache = new MemoryCache(); + cache.populate(cached, new HashMap<>(Map.of("Points", new DataValueInt(1)))); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertEquals(1, runtime.read(cached, "Points", UserDataFetchMode.CACHE_ONLY, null, + new DataValueInt(0)).getInt()); + assertDoesNotThrow(() -> runtime.queueChange(cached, "Points", new DataValueInt(7))); + assertEquals(7, cache.getIfPresent(cached, "Points").getInt()); + assertThrows(IllegalStateException.class, + () -> runtime.queueChange(uncached, "Points", new DataValueInt(9))); + } + } + + private static final class MemoryCache implements UserCacheOwner { + private final Map> values = new HashMap<>(); + public boolean isCached(UUID uuid) { return values.containsKey(uuid); } + public DataValue getIfPresent(UUID uuid, String key) { + HashMap row = values.get(uuid); + return row == null ? null : row.get(key); + } + public void populate(UUID uuid, HashMap row) { values.put(uuid, new HashMap<>(row)); } + public void queueChange(UUID uuid, String key, DataValue value) { values.get(uuid).put(key, value); } + public void flush(UUID uuid, SqlUserStorage storage) {} + public Set cachedUsers() { return Set.copyOf(values.keySet()); } + public void remove(UUID uuid) { values.remove(uuid); } + public void clearAfterFlush() { values.clear(); } + public void shutdown() {} + @Override public void requireBlockingAllowed() { + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) throw new IllegalStateException("blocking SQL"); + } + } + + private static final class MemoryBackend implements SqlUserBackend { + public UserStorage storageType() { return UserStorage.SQLITE; } + public SqlUserStorage user(UUID uuid) { + return new SqlUserStorage() { + public List readRow(UserStorage storage) { return List.of(); } + public boolean contains(UserStorage storage) { return false; } + public void delete(UserStorage storage) {} + public void write(UserStorage storage, String key, DataValue value) {} + public void writeValues(UserStorage storage, HashMap values) {} + }; + } + public List enumerateUsers() { return List.of(); } + public boolean isOpen() { return true; } + public void close() {} + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java new file mode 100644 index 0000000000..6a0427dfb9 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java @@ -0,0 +1,210 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; + +class SharedLegacyBackendRoutingTest { + @Test + void directLegacyCallCannotSlipIntoAnActiveBindingTransition() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL legacy = mock(MySQL.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getMysql()).thenReturn(legacy); + UUID uuid = UUID.randomUUID(); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, uuid::toString); + manager.beginSharedBindingTransition(); + try { + assertThrows(IllegalStateException.class, () -> adapter.contains(UserStorage.MYSQL)); + verifyNoInteractions(legacy); + } finally { + manager.endSharedBindingTransition(); + manager.getTimer().shutdownNow(); + } + } + + @Test + void managerPresentLegacyRoutePreservesOpaqueStringIdentifiers() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL legacy = mock(MySQL.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getMysql()).thenReturn(legacy); + when(legacy.containsKey("legacy-user-key")).thenReturn(true); + try { + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, () -> "legacy-user-key"); + assertTrue(adapter.contains(UserStorage.MYSQL)); + verify(legacy).containsKey("legacy-user-key"); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void directLegacyWriteIsAdmittedBeforeAReplacementCanBePublished() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL legacy = mock(MySQL.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getMysql()).thenReturn(legacy); + UUID uuid = UUID.randomUUID(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }).when(legacy).update(eq(uuid.toString()), eq("Points"), any(DataValue.class)); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, uuid::toString); + var worker = Executors.newSingleThreadExecutor(); + try { + var write = worker.submit(() -> adapter.write(UserStorage.MYSQL, "Points", new DataValueInt(1))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, manager::beginSharedBindingTransition); + release.countDown(); + write.get(5, TimeUnit.SECONDS); + manager.beginSharedBindingTransition(); + manager.endSharedBindingTransition(); + verify(legacy).update(eq(uuid.toString()), eq("Points"), any(DataValue.class)); + } finally { + release.countDown(); + worker.shutdownNow(); + manager.getTimer().shutdownNow(); + } + } + + @Test + void legacyAdapterRejectsCrossStoreWriteInsteadOfSilentlyWritingTheSharedStore() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + UUID uuid = UUID.randomUUID(); + RecordingStorage target = new RecordingStorage(); + SqlUserBackend backend = new SqlUserBackend() { + public UserStorage storageType() { return UserStorage.SQLITE; } + public SqlUserStorage user(UUID requested) { assertEquals(uuid, requested); return target; } + public List enumerateUsers() { return List.of(uuid); } + public boolean isOpen() { return true; } + public void close() {} + }; + manager.bindSharedSqlBackend(backend, Runnable::run); + when(plugin.getUserManager().getDataManager()).thenReturn(manager); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, () -> uuid.toString()); + DataValueInt value = new DataValueInt(9); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> adapter.write(UserStorage.MYSQL, "Points", value)); + assertTrue(failure.getMessage().contains("MYSQL")); + assertTrue(failure.getMessage().contains("SQLITE")); + assertEquals(null, target.lastStorage); + assertEquals(null, target.lastValue); + manager.getTimer().shutdownNow(); + } + + @Test + void legacyAdapterPreservesTheExplicitTargetWhenItMatchesTheSharedStore() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + UUID uuid = UUID.randomUUID(); + RecordingStorage target = new RecordingStorage(); + SqlUserBackend backend = new SqlUserBackend() { + public UserStorage storageType() { return UserStorage.SQLITE; } + public SqlUserStorage user(UUID requested) { assertEquals(uuid, requested); return target; } + public List enumerateUsers() { return List.of(uuid); } + public boolean isOpen() { return true; } + public void close() {} + }; + manager.bindSharedSqlBackend(backend, Runnable::run); + when(plugin.getUserManager().getDataManager()).thenReturn(manager); + try { + new BukkitSqlUserStorage(() -> plugin, uuid::toString) + .write(UserStorage.SQLITE, "Points", new DataValueInt(9)); + assertEquals(UserStorage.SQLITE, target.lastStorage); + assertEquals(9, target.lastValue.getInt()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void explicitRuntimeMaintenanceCanUseTheConverterTargetWithoutWeakeningNormalCrossStoreProtection() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + UserTable sqlite = mock(UserTable.class); + UUID uuid = UUID.randomUUID(); + SqlUserBackend backend = mock(SqlUserBackend.class); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getSQLiteUserTable()).thenReturn(sqlite); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, Runnable::run); + manager.bindSharedRuntime(runtime); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return null; + }).when(runtime).runStorageMaintenance(any(Runnable.class)); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, uuid::toString); + try { + assertThrows(IllegalStateException.class, + () -> adapter.write(UserStorage.SQLITE, "Points", new DataValueInt(1))); + + manager.runStorageMaintenance(() -> + adapter.write(UserStorage.SQLITE, "Points", new DataValueInt(2))); + + verify(runtime).runStorageMaintenance(any(Runnable.class)); + verify(sqlite).update(any(Column.class), any()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + private static final class RecordingStorage implements SqlUserStorage { + UserStorage lastStorage; + DataValue lastValue; + public List readRow(UserStorage storage) { lastStorage = storage; return List.of(); } + public boolean contains(UserStorage storage) { lastStorage = storage; return true; } + public void delete(UserStorage storage) { lastStorage = storage; } + public void write(UserStorage storage, String key, DataValue value) { lastStorage = storage; lastValue = value; } + public void writeValues(UserStorage storage, HashMap values) { lastStorage = storage; } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java new file mode 100644 index 0000000000..8af6786a69 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java @@ -0,0 +1,112 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class SharedRouteReplacementRaceTest { + @Test + void legacyOperationResolvesTheBackendAfterLifecycleAdmission() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + SqlUserBackend oldBackend = mock(SqlUserBackend.class); + SqlUserBackend newBackend = mock(SqlUserBackend.class); + SqlUserStorage newStorage = mock(SqlUserStorage.class); + when(newBackend.isOpen()).thenReturn(true); + when(newBackend.storageType()).thenReturn(UserStorage.SQLITE); + when(newBackend.user(uuid)).thenReturn(newStorage); + + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + BiConsumer gate = (id, operation) -> { + entered.countDown(); + await(release); + operation.run(); + }; + manager.bindSharedSqlBackend(oldBackend, gate); + + var worker = Executors.newSingleThreadExecutor(); + try { + var result = worker.submit(() -> manager.withSharedSqlBackend(uuid, (type, storage) -> { + storage.contains(type); + return type; + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + manager.bindSharedSqlBackend(newBackend, gate); + release.countDown(); + assertEquals(UserStorage.SQLITE, result.get(5, TimeUnit.SECONDS)); + verify(newBackend).user(uuid); + verify(newStorage).contains(UserStorage.SQLITE); + verify(oldBackend, never()).user(any()); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void detachedCacheWriterFollowsBackendReplacement() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + BukkitUserCacheOwner owner = new BukkitUserCacheOwner(manager); + SqlUserBackend oldBackend = mock(SqlUserBackend.class); + SqlUserBackend newBackend = mock(SqlUserBackend.class); + SqlUserStorage oldStorage = mock(SqlUserStorage.class); + SqlUserStorage newStorage = mock(SqlUserStorage.class); + when(oldBackend.isOpen()).thenReturn(true); + when(oldBackend.storageType()).thenReturn(UserStorage.MYSQL); + when(oldBackend.user(uuid)).thenReturn(oldStorage); + when(newBackend.isOpen()).thenReturn(true); + when(newBackend.storageType()).thenReturn(UserStorage.SQLITE); + when(newBackend.user(uuid)).thenReturn(newStorage); + + BiConsumer perUser = (id, operation) -> operation.run(); + owner.bindLifecycle(oldBackend, Runnable::run, perUser); + UserDataCache detached = new UserDataCache(manager, uuid); + detached.addChange(new UserDataChangeInt("Points", 1), false); + owner.bindBackend(newBackend); + manager.getUserDataCache().put(uuid, detached); + detached.addChange(new UserDataChangeInt("Points", 2), true); + detached.processChanges(); + + verify(newStorage).writeValues(any(UserStorage.class), any(HashMap.class)); + verify(oldStorage, never()).writeValues(any(UserStorage.class), any(HashMap.class)); + manager.getTimer().shutdownNow(); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("timed out waiting for lifecycle test latch"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java new file mode 100644 index 0000000000..146f8d057a --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java @@ -0,0 +1,374 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +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; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.sql.data.DataValueString; + +class SharedUserDataRuntimeTest { + @Test + void preservesTempCacheThenCacheThenStoragePrecedenceAcrossFetchModes() { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "value", new DataValueString("storage")); + FakeCacheOwner cache = new FakeCacheOwner(); + cache.populate(uuid, values("value", new DataValueString("cache"))); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + + HashMap temp = values("value", new DataValueString("temp")); + assertEquals("temp", runtime.read(uuid, "value", UserDataFetchMode.DEFAULT, temp, + new DataValueString("default")).getString()); + assertEquals("cache", runtime.read(uuid, "value", UserDataFetchMode.CACHE_ONLY, null, + new DataValueString("default")).getString()); + assertEquals("storage", runtime.read(uuid, "value", UserDataFetchMode.NO_CACHE, null, + new DataValueString("default")).getString()); + assertEquals("default", runtime.read(uuid, "missing", UserDataFetchMode.TEMP_ONLY, temp, + new DataValueString("default")).getString()); + assertEquals("default", runtime.read(uuid, "missing", UserDataFetchMode.NO_DB_LOOKUP, null, + new DataValueString("default")).getString()); + + UUID uncached = UUID.randomUUID(); + backend.put(uncached, "value", new DataValueString("direct")); + int populations = cache.populateCalls; + assertEquals("direct", runtime.read(uncached, "value", UserDataFetchMode.NO_WAIT, null, + new DataValueString("default")).getString()); + assertEquals(populations, cache.populateCalls); + } + + @Test + void startupEnumerationCanPopulateExistingCacheOwner() { + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(first, "Points", new DataValueInt(2)); + backend.put(second, "Points", new DataValueInt(7)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + Set seen = new HashSet<>(); + + int count = runtime.startupForEach((uuid, values) -> seen.add(uuid), true); + + assertEquals(2, count); + assertEquals(Set.of(first, second), seen); + assertEquals(2, cache.getIfPresent(first, "Points").getInt()); + assertEquals(7, cache.getIfPresent(second, "Points").getInt()); + } + + @Test + void queuedChangesFlushBeforeShutdownAndBackendReplacement() { + UUID uuid = UUID.randomUUID(); + FakeBackend first = new FakeBackend(); + first.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(first, cache); + + runtime.queueChange(uuid, "Points", new DataValueInt(5)); + runtime.flush(uuid); + assertEquals(5, first.value(uuid, "Points").getInt()); + + FakeBackend second = new FakeBackend(); + runtime.queueChange(uuid, "Points", new DataValueInt(9)); + runtime.replaceBackend(second); + assertEquals(9, first.value(uuid, "Points").getInt()); + assertFalse(first.isOpen()); + assertSame(second, runtime.backend()); + assertTrue(cache.cachedUsers().isEmpty()); + + runtime.queueChange(uuid, "Points", new DataValueInt(11)); + runtime.close(); + assertEquals(11, second.value(uuid, "Points").getInt()); + assertFalse(second.isOpen()); + assertTrue(cache.shutdown); + assertTrue(runtime.isClosed()); + } + + @Test + void failedOldBackendCloseDoesNotTearDownThePublishedReplacement() { + SqlUserBackend old = mock(SqlUserBackend.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + SqlUserBackend laterReplacement = mock(SqlUserBackend.class); + when(old.isOpen()).thenReturn(true); + when(replacement.isOpen()).thenReturn(true); + when(laterReplacement.isOpen()).thenReturn(true); + when(old.storageType()).thenReturn(UserStorage.SQLITE); + when(replacement.storageType()).thenReturn(UserStorage.MYSQL); + when(laterReplacement.storageType()).thenReturn(UserStorage.SQLITE); + java.util.concurrent.atomic.AtomicBoolean firstClose = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(ignored -> { + if (firstClose.getAndSet(false)) throw new IllegalStateException("old close failed"); + return null; + }).when(old).close(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(old, new FakeCacheOwner()); + + assertDoesNotThrow(() -> runtime.replaceBackend(replacement)); + assertSame(replacement, runtime.backend()); + assertDoesNotThrow(() -> runtime.replaceBackend(laterReplacement)); + assertSame(laterReplacement, runtime.backend()); + verify(old, times(2)).close(); + } + + @Test + void successfulShutdownDiscardsFinalFlushNotifications() { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + runtime.queueChange(uuid, "Points", new DataValueInt(2)); + boolean[] notified = { false }; + cache.notifyAfterFlush(uuid, () -> notified[0] = true); + + runtime.close(); + + assertEquals(2, backend.value(uuid, "Points").getInt()); + assertFalse(notified[0]); + assertTrue(cache.notifications.isEmpty()); + } + + @Test + void failedFlushDoesNotDiscardQueueOrCloseBackend() { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + runtime.queueChange(uuid, "Points", new DataValueInt(3)); + backend.failWrites = true; + + assertThrows(IllegalStateException.class, runtime::close); + assertTrue(backend.isOpen()); + assertFalse(runtime.isClosed()); + assertTrue(cache.hasPending(uuid)); + + backend.failWrites = false; + runtime.close(); + assertEquals(3, backend.value(uuid, "Points").getInt()); + } + + @Test + void changeNotificationCanRequestExclusiveUserWorkAfterFlushAdmissionIsReleased() throws Exception { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + runtime.queueChange(uuid, "Points", new DataValueInt(2)); + cache.notifyAfterFlush(uuid, () -> runtime.remove(uuid)); + ExecutorService worker = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "shared-user-notification-test"); + thread.setDaemon(true); + return thread; + }); + try { + Future flush = worker.submit(() -> runtime.flush(uuid)); + flush.get(2, TimeUnit.SECONDS); + assertFalse(backend.rows.containsKey(uuid)); + } finally { + worker.shutdownNow(); + } + } + + private static HashMap values(String key, DataValue value) { + HashMap result = new HashMap<>(); + result.put(key, value); + return result; + } + + private static final class FakeCacheOwner implements UserCacheOwner { + private final Map> cache = new HashMap<>(); + private final Map> pending = new HashMap<>(); + private final Map notifications = new HashMap<>(); + private int populateCalls; + private boolean shutdown; + + @Override + public boolean isCached(UUID uuid) { + return cache.containsKey(uuid); + } + + @Override + public DataValue getIfPresent(UUID uuid, String key) { + HashMap values = cache.get(uuid); + return values == null ? null : values.get(key); + } + + @Override + public void populate(UUID uuid, HashMap values) { + populateCalls++; + cache.put(uuid, new HashMap<>(values)); + } + + @Override + public void queueChange(UUID uuid, String key, DataValue value) { + cache.computeIfAbsent(uuid, ignored -> new HashMap<>()).put(key, value); + pending.computeIfAbsent(uuid, ignored -> new HashMap<>()).put(key, value); + } + + @Override + public void flush(UUID uuid, SqlUserStorage storage) { + HashMap changes = pending.get(uuid); + if (changes == null || changes.isEmpty()) { + return; + } + storage.writeValues(UserStorage.SQLITE, new HashMap<>(changes)); + pending.remove(uuid); + } + + void notifyAfterFlush(UUID uuid, Runnable notification) { + notifications.put(uuid, notification); + } + + @Override + public void dispatchNotifications(UUID uuid) { + Runnable notification = notifications.remove(uuid); + if (notification != null) notification.run(); + } + + @Override + public void dispatchAllNotifications() { + for (UUID uuid : Set.copyOf(notifications.keySet())) dispatchNotifications(uuid); + } + + @Override + public void discardAllNotifications() { notifications.clear(); } + + @Override + public Set cachedUsers() { + return new HashSet<>(cache.keySet()); + } + + @Override + public void remove(UUID uuid) { + cache.remove(uuid); + pending.remove(uuid); + } + + @Override + public void clearAfterFlush() { + cache.clear(); + } + + @Override + public void shutdown() { + shutdown = true; + } + + boolean hasPending(UUID uuid) { + return pending.containsKey(uuid) && !pending.get(uuid).isEmpty(); + } + } + + private static final class FakeBackend implements SqlUserBackend { + private final Map> rows = new LinkedHashMap<>(); + private boolean open = true; + private boolean failWrites; + + void put(UUID uuid, String key, DataValue value) { + rows.computeIfAbsent(uuid, ignored -> new LinkedHashMap<>()).put(key, value); + } + + DataValue value(UUID uuid, String key) { + Map row = rows.get(uuid); + return row == null ? null : row.get(key); + } + + @Override + public UserStorage storageType() { + return UserStorage.SQLITE; + } + + @Override + public SqlUserStorage user(UUID uuid) { + if (!open) { + throw new IllegalStateException("closed"); + } + return new SqlUserStorage() { + @Override + public List readRow(UserStorage storage) { + ArrayList result = new ArrayList<>(); + Map row = rows.get(uuid); + if (row != null) { + for (Map.Entry entry : row.entrySet()) { + result.add(new Column(entry.getKey(), entry.getValue())); + } + } + return result; + } + + @Override + public boolean contains(UserStorage storage) { + return rows.containsKey(uuid); + } + + @Override + public void delete(UserStorage storage) { + rows.remove(uuid); + } + + @Override + public void write(UserStorage storage, String key, DataValue value) { + if (failWrites) { + throw new IllegalStateException("write failed"); + } + put(uuid, key, value); + } + + @Override + public void writeValues(UserStorage storage, HashMap values) { + if (failWrites) { + throw new IllegalStateException("write failed"); + } + rows.computeIfAbsent(uuid, ignored -> new LinkedHashMap<>()).putAll(values); + } + }; + } + + @Override + public List enumerateUsers() { + return new ArrayList<>(rows.keySet()); + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() { + open = false; + } + } +} 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 new file mode 100644 index 0000000000..672254794c --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java @@ -0,0 +1,392 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +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; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +/** Memory providers plus real cache/queue code; not live database or game-server integration. */ +@Timeout(15) +class SharedUserLifecycleRegressionTest { + @Test + void closeDrainsAdmittedQueueBeforeTeardownAndRejectsLateWrites() throws Exception { + UUID uuid = UUID.randomUUID(); + UserCacheOwner owner = mock(UserCacheOwner.class); + SqlUserBackend backend = mock(SqlUserBackend.class); + SqlUserStorage storage = mock(SqlUserStorage.class); + when(backend.isOpen()).thenReturn(true); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); + when(backend.user(uuid)).thenReturn(storage); + when(owner.isCached(uuid)).thenReturn(true); + when(owner.cachedUsers()).thenReturn(Set.of(uuid)); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + doAnswer(call -> { entered.countDown(); await(release); return null; }) + .when(owner).queueChange(eq(uuid), anyString(), any()); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future write = workers.submit(() -> runtime.queueChange(uuid, "Points", new DataValueInt(5))); + await(entered); + CompletableFuture closed = runtime.closeAsync(workers).toCompletableFuture(); + assertTrue(runtime.isRetiring()); + assertFalse(closed.isDone()); + assertThrows(IllegalStateException.class, + () -> runtime.queueChange(uuid, "Points", new DataValueInt(9))); + verify(owner, never()).clearAfterFlush(); + release.countDown(); + write.get(5, TimeUnit.SECONDS); + closed.get(5, TimeUnit.SECONDS); + var order = inOrder(owner, backend); + order.verify(owner).flush(uuid, UserStorage.SQLITE, storage); + order.verify(owner).clearAfterFlush(); + order.verify(owner).shutdown(); + order.verify(backend).close(); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void rejectedAsyncCloseKeepsPendingChangesForAnObservedRetry() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(6)); + var result = runtime.closeAsync(task -> { throw new RejectedExecutionException("stopped"); }); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + assertTrue(runtime.isRetiring()); + assertTrue(fixture.first.isOpen()); + assertTrue(fixture.caches.get(fixture.uuid).hasChangesToProcess()); + runtime.close(); + assertEquals(6, fixture.first.points(fixture.uuid)); + } + + @Test + void failedFlushKeepsQueueAndProviderButDoesNotReopenAdmission() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(6)); + fixture.first.beforeWrite = () -> { throw new IllegalStateException("write failed"); }; + assertThrows(IllegalStateException.class, runtime::close); + assertTrue(fixture.first.isOpen()); + assertFalse(runtime.isClosed()); + assertTrue(fixture.caches.get(fixture.uuid).hasChangesToProcess()); + assertThrows(IllegalStateException.class, + () -> runtime.queueChange(fixture.uuid, "Points", new DataValueInt(9))); + fixture.first.beforeWrite = () -> {}; + runtime.close(); + assertEquals(6, fixture.first.points(fixture.uuid)); + } + + @Test + void explicitAndScheduledFlushesFollowReplacementRatherThanPluginSettings() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(5)); + fixture.tasks.get(0).run(); + assertEquals(5, fixture.first.points(fixture.uuid)); + UserDataCache oldCache = fixture.caches.get(fixture.uuid); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(7)); + MemoryBackend second = new MemoryBackend(UserStorage.MYSQL); + runtime.replaceBackend(second); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertFalse(fixture.first.isOpen()); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(11)); + for (Runnable task : List.copyOf(fixture.tasks)) task.run(); + assertEquals(11, second.points(fixture.uuid)); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertThrows(IllegalStateException.class, + () -> oldCache.addChange(new UserDataChangeInt("Points", 99), true)); + verify(fixture.plugin.getUserManager().getUser(fixture.uuid, false).getUserData(), never()) + .setValues(any(HashMap.class)); + runtime.close(); + } + + @Test + void sameBackendReplacementDoesNotCloseItOrDiscardPendingWrites() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(4)); + runtime.replaceBackend(fixture.first); + assertTrue(fixture.first.isOpen()); + assertTrue(fixture.caches.get(fixture.uuid).hasChangesToProcess()); + runtime.close(); + assertEquals(4, fixture.first.points(fixture.uuid)); + } + + @Test + void scheduledBatchCompletesWithoutKeepingNotificationInsideTheShutdownBarrier() throws Exception { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicBoolean firstWrite = new AtomicBoolean(true); + fixture.first.beforeWrite = () -> { + if (firstWrite.getAndSet(false)) { entered.countDown(); await(release); } + }; + var userManager = fixture.plugin.getUserManager(); + CountDownLatch notified = new CountDownLatch(1); + doAnswer(call -> { notified.countDown(); return null; }) + .when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future scheduled = workers.submit(fixture.tasks.get(0)); + await(entered); + CompletableFuture closed = runtime.closeAsync(workers).toCompletableFuture(); + assertFalse(closed.isDone()); + release.countDown(); + scheduled.get(5, TimeUnit.SECONDS); + closed.get(5, TimeUnit.SECONDS); + await(notified); + assertEquals(3, fixture.first.points(fixture.uuid)); + assertTrue(runtime.isClosed()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void changeCallbackCanTakeExclusiveUserAdmissionForTheSameUuid() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + var userManager = fixture.plugin.getUserManager(); + doAnswer(call -> { runtime.remove(fixture.uuid); return null; }) + .when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + + assertDoesNotThrow(cache::processChanges); + assertFalse(fixture.caches.containsKey(fixture.uuid)); + runtime.close(); + } + + @Test + void callbackCanClearTheCacheAfterItsSharedBatchCompletes() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + var userManager = fixture.plugin.getUserManager(); + doAnswer(call -> { + cache.clearCache(); + return null; + }).when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + + assertDoesNotThrow(cache::processChanges); + assertTrue(cache.getCache().isEmpty()); + + runtime.close(); + } + + @Test + void blockingCloseIsRejectedOnServerThreadButAsyncCloseDoesNotWaitThere() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + List workerQueue = new ArrayList<>(); + CompletionStage closed; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertThrows(IllegalStateException.class, runtime::close); + assertFalse(runtime.isRetiring()); + closed = runtime.closeAsync(workerQueue::add); + assertFalse(closed.toCompletableFuture().isDone()); + assertEquals(1, workerQueue.size()); + } + workerQueue.get(0).run(); + closed.toCompletableFuture().join(); + assertTrue(runtime.isClosed()); + } + + @Test + void directWritesToTheBoundCacheAlsoRejectRetirement() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + List workers = new ArrayList<>(); + var closed = runtime.closeAsync(workers::add).toCompletableFuture(); + assertThrows(IllegalStateException.class, + () -> cache.addChange(new UserDataChangeInt("Points", 9), true)); + workers.get(0).run(); + closed.join(); + assertEquals(3, fixture.first.points(fixture.uuid)); + } + + @Test + void directPrimaryThreadPublicationCannotRaceAnExclusiveDelete() throws Exception { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.populate(fixture.uuid); + UserDataCache cache = fixture.caches.get(fixture.uuid); + CountDownLatch deleteStarted = new CountDownLatch(1), releaseDelete = new CountDownLatch(1); + fixture.first.beforeDelete = () -> { + deleteStarted.countDown(); + await(releaseDelete); + }; + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future removal = worker.submit(() -> runtime.remove(fixture.uuid)); + await(deleteStarted); + assertFalse(cache.tryAddChangeBeforeDeferredSharedFlush(new UserDataChangeInt("Points", 9))); + releaseDelete.countDown(); + removal.get(5, TimeUnit.SECONDS); + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + assertFalse(fixture.caches.containsKey(fixture.uuid)); + } finally { + releaseDelete.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + runtime.close(); + } + } + + @Test + void directPrimaryThreadPublicationCannotRaceBackendReplacement() throws Exception { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(7)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.beforeWrite = () -> { + writeStarted.countDown(); + await(releaseWrite); + }; + MemoryBackend replacement = new MemoryBackend(UserStorage.MYSQL); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future replacing = worker.submit(() -> runtime.replaceBackend(replacement)); + await(writeStarted); + assertFalse(cache.tryAddChangeBeforeDeferredSharedFlush(new UserDataChangeInt("Points", 9))); + releaseWrite.countDown(); + replacing.get(5, TimeUnit.SECONDS); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertFalse(fixture.caches.containsKey(fixture.uuid)); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + runtime.close(); + } + } + + @Test + void shutdownCancelsDelayedTimerWorkWithoutAwaitingIt() throws Exception { + Fixture fixture = new Fixture(); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(fixture.manager.getTimer()).thenReturn(timer); + try { + ScheduledFuture delayed = timer.schedule((Runnable) () -> fail("redundant delayed task ran"), 1, TimeUnit.HOURS); + fixture.owner.shutdown(); + assertFalse(timer.getExecuteExistingDelayedTasksAfterShutdownPolicy()); + assertTrue(delayed.isCancelled()); + assertTrue(timer.awaitTermination(2, TimeUnit.SECONDS)); + } finally { timer.shutdownNow(); } + } + + private static void await(CountDownLatch latch) { + try { assertTrue(latch.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + } + + private static final class Fixture { + final UUID uuid = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + final UserDataManager manager = mock(UserDataManager.class); + final ConcurrentHashMap caches = new ConcurrentHashMap<>(); + final List tasks = new CopyOnWriteArrayList<>(); + final MemoryBackend first = new MemoryBackend(UserStorage.SQLITE); + final BukkitUserCacheOwner owner; + Fixture() { + when(manager.getPlugin()).thenReturn(plugin); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(manager.getUserDataCache()).thenReturn(caches); + when(manager.isCached(any(UUID.class))).thenAnswer(call -> { + UserDataCache cache = caches.get(call.getArgument(0)); + return cache != null && cache.hasCache(); + }); + when(manager.retireSharedCache(any(UUID.class), nullable(UserDataCache.class))).thenAnswer(call -> { + UUID cachedUuid = call.getArgument(0); + UserDataCache expected = call.getArgument(1); + if (caches.get(cachedUuid) != expected) return false; + if (expected != null) caches.remove(cachedUuid, expected); + return true; + }); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(manager.getTimer()).thenReturn(timer); + when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return mock(ScheduledFuture.class); + }); + first.rows.put(uuid, new HashMap<>(Map.of("Points", new DataValueInt(1)))); + owner = new BukkitUserCacheOwner(manager); + } + SharedUserDataRuntime runtime() { return new SharedUserDataRuntime(first, owner); } + } + + private static final class MemoryBackend implements SqlUserBackend { + final UserStorage type; + final Map> rows = new ConcurrentHashMap<>(); + volatile boolean open = true; + volatile Runnable beforeWrite = () -> {}; + volatile Runnable beforeDelete = () -> {}; + MemoryBackend(UserStorage type) { this.type = type; } + int points(UUID uuid) { return rows.get(uuid).get("Points").getInt(); } + public UserStorage storageType() { return type; } + public boolean isOpen() { return open; } + public void close() { open = false; } + public List enumerateUsers() { return new ArrayList<>(rows.keySet()); } + public SqlUserStorage user(UUID uuid) { + if (!open) throw new IllegalStateException("closed"); + return new SqlUserStorage() { + public List readRow(UserStorage requested) { + List result = new ArrayList<>(); + rows.getOrDefault(uuid, new HashMap<>()).forEach((key, value) -> result.add(new Column(key, value))); + return result; + } + public boolean contains(UserStorage requested) { return rows.containsKey(uuid); } + public void delete(UserStorage requested) { beforeDelete.run(); rows.remove(uuid); } + public void write(UserStorage requested, String key, DataValue value) { + writeValues(requested, new HashMap<>(Map.of(key, value))); + } + public void writeValues(UserStorage requested, HashMap values) { + assertEquals(type, requested); + if (!open) throw new IllegalStateException("closed"); + beforeWrite.run(); + rows.computeIfAbsent(uuid, ignored -> new HashMap<>()).putAll(values); + } + }; + } + } +} 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 new file mode 100644 index 0000000000..dc421d2968 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java @@ -0,0 +1,313 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +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.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; +import com.bencodez.simpleapi.sql.data.DataValue; + +class SharedUserStorageReloadSafetyTest { + @Test + void sharedStorageReloadExposesAnIncompleteCompletionStageUntilReplacementFinishes() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + when(manager.hasSharedRuntime()).thenReturn(true); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getLoadedUserManager()).thenReturn(users); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + + CompletableFuture completion = plugin.reloadAdvancedCoreAsync(true).toCompletableFuture(); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + verify(plugin, never()).getServerDataFile(); + assertFalse(completion.isDone()); + } + + @Test + void publicStorageTypeRemainsPinnedToTheActiveSharedBackendAfterConfigReload() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(options.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getOptions()).thenReturn(options); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + try { + assertEquals(UserStorage.MYSQL, plugin.getStorageType()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void bulkApisUseTheNativeOwnerCapturedWithTheSharedRoute() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL replacementOwner = mock(MySQL.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + when(replacement.storageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, replacementOwner, null)); + // Simulate the interval in a cross-type replacement where a caller could + // otherwise observe a new route type and an unrelated legacy field. + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(replacementOwner.getColumns()).thenReturn(List.of("uuid", "PlayerName")); + UserManager users = new UserManager(plugin); + try { + users.getDataManager().bindSharedSqlBackend(replacement, (uuid, operation) -> operation.run()); + + assertEquals(List.of("uuid", "PlayerName"), users.getAllColumns()); + verify(plugin, never()).getSQLiteUserTable(); + } finally { + users.getDataManager().getTimer().shutdownNow(); + } + } + + @Test + void bulkOperationHoldsLifecycleAdmissionUntilTheOldProviderIsNoLongerInUse() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL oldMysql = mock(MySQL.class); + MySQL replacementMysql = mock(MySQL.class); + SqlUserBackend oldBackend = mock(SqlUserBackend.class); + SqlUserBackend replacementBackend = mock(SqlUserBackend.class); + when(oldBackend.storageType()).thenReturn(UserStorage.MYSQL); + when(oldBackend.isOpen()).thenReturn(true); + when(replacementBackend.storageType()).thenReturn(UserStorage.MYSQL); + when(replacementBackend.isOpen()).thenReturn(true); + AtomicReference owner = new AtomicReference<>( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, oldMysql, null)); + when(plugin.getNativeUserStorageOwner()).thenAnswer(ignored -> owner.get()); + CountDownLatch bulkEntered = new CountDownLatch(1); + CountDownLatch releaseBulk = new CountDownLatch(1); + CountDownLatch oldClosed = new CountDownLatch(1); + when(oldMysql.getColumns()).thenAnswer(ignored -> { + bulkEntered.countDown(); + assertTrue(releaseBulk.await(5, TimeUnit.SECONDS)); + return List.of("uuid"); + }); + doAnswer(ignored -> { oldClosed.countDown(); return null; }).when(oldBackend).close(); + + UserManager users = new UserManager(plugin); + RoutingCacheOwner cacheOwner = new RoutingCacheOwner(users.getDataManager()); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(oldBackend, cacheOwner); + users.getDataManager().bindSharedRuntime(runtime); + AtomicReference> result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread bulk = new Thread(() -> { + try { result.set(users.getAllColumns()); } + catch (Throwable thrown) { failure.set(thrown); } + }, "bulk-owner-admission"); + Thread replacement = new Thread(() -> runtime.replaceBackend(replacementBackend, + () -> owner.set(new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, replacementMysql, null))), + "replace-owner-admission"); + try { + bulk.start(); + assertTrue(bulkEntered.await(5, TimeUnit.SECONDS)); + replacement.start(); + assertFalse(oldClosed.await(200, TimeUnit.MILLISECONDS)); + releaseBulk.countDown(); + bulk.join(5000); + replacement.join(5000); + assertFalse(bulk.isAlive()); + assertFalse(replacement.isAlive()); + assertEquals(List.of("uuid"), result.get()); + assertEquals(null, failure.get()); + assertTrue(oldClosed.await(1, TimeUnit.SECONDS)); + } finally { + releaseBulk.countDown(); + users.getDataManager().getTimer().shutdownNow(); + } + } + + @Test + void sharedBackendReplacementCompletesOnTheManagerWorkerWithoutBlockingTheCaller() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + when(runtime.isClosed()).thenReturn(false); + manager.bindSharedRuntime(runtime); + try { + manager.replaceSharedSqlBackendAsync(replacement).toCompletableFuture().get(5, TimeUnit.SECONDS); + verify(runtime).replaceBackend(eq(replacement), any(Runnable.class)); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void retirementContinuesAfterAnInFlightReplacementFails() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + when(runtime.isClosed()).thenReturn(false); + CountDownLatch replacementStarted = new CountDownLatch(1); + CountDownLatch releaseReplacement = new CountDownLatch(1); + IllegalStateException replacementFailure = new IllegalStateException("replacement failed"); + doAnswer(ignored -> { + replacementStarted.countDown(); + assertTrue(releaseReplacement.await(5, TimeUnit.SECONDS)); + throw replacementFailure; + }).when(runtime).replaceBackend(eq(replacement), any(Runnable.class)); + when(runtime.closeAsync(any())).thenReturn(CompletableFuture.completedFuture(null)); + manager.bindSharedRuntime(runtime); + CountDownLatch retired = new CountDownLatch(1); + try { + CompletionStage replacementStage = manager.replaceSharedSqlBackendAsync(replacement); + assertTrue(replacementStarted.await(5, TimeUnit.SECONDS)); + CompletionStage retirement = manager.closeSharedRuntimeAsyncCompletion(retired::countDown); + assertFalse(retirement.toCompletableFuture().isDone()); + releaseReplacement.countDown(); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> replacementStage.toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> retirement.toCompletableFuture().join()); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + verify(runtime).closeAsync(any()); + } finally { + releaseReplacement.countDown(); + manager.getTimer().shutdownNow(); + } + } + + @Test + void directNativeStorageMutationRejectsWhileSharedRuntimeOwnsStorage() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(runtime.isClosed()).thenReturn(false); + manager.bindSharedRuntime(runtime); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + try { + assertThrows(IllegalStateException.class, () -> plugin.loadUserAPI(UserStorage.MYSQL)); + verify(plugin, never()).getOptions(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void retirementRemainsAnUnsafeReloadWindowUntilBlockingCloseCompletes() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + CompletableFuture retirement = new CompletableFuture<>(); + CountDownLatch afterRetirement = new CountDownLatch(1); + when(runtime.isClosed()).thenReturn(false); + when(runtime.closeAsync(any())).thenReturn(retirement); + manager.bindSharedRuntime(runtime); + try { + assertTrue(manager.closeSharedRuntimeAsync(afterRetirement::countDown)); + assertTrue(manager.hasSharedRuntimeLifecycle()); + assertTrue(manager.closeSharedRuntimeAsync(() -> { + throw new AssertionError("A second caller must join the existing retirement"); + })); + retirement.complete(null); + assertTrue(afterRetirement.await(5, TimeUnit.SECONDS)); + assertFalse(manager.hasSharedRuntimeLifecycle()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void conversionRunsBehindTheRuntimeMaintenanceBarrierInsteadOfTheReloadGuard() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(users.getAllKeys(UserStorage.SQLITE)).thenReturn(new HashMap<>()); + doNothing().when(plugin).loadUserAPI(any(UserStorage.class)); + doNothing().when(plugin).debug(any(String.class)); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return null; + }).when(runtime).runStorageMaintenance(any(Runnable.class)); + manager.bindSharedRuntime(runtime); + try { + plugin.convertDataStorage(UserStorage.SQLITE, UserStorage.MYSQL); + + verify(runtime).runStorageMaintenance(any(Runnable.class)); + verify(plugin).loadUserAPI(UserStorage.SQLITE); + verify(plugin).loadUserAPI(UserStorage.MYSQL); + } finally { + manager.getTimer().shutdownNow(); + } + } + + private static final class RoutingCacheOwner implements UserCacheOwner { + private final UserDataManager manager; + private Consumer lifecycleGate; + private BiConsumer userGate; + private BiConsumer exclusiveUserGate; + + private RoutingCacheOwner(UserDataManager manager) { this.manager = manager; } + + @Override public void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer perUserGate, BiConsumer perUserExclusiveGate) { + lifecycleGate = gate; + userGate = perUserGate; + exclusiveUserGate = perUserExclusiveGate; + manager.bindSharedSqlBackend(backend, gate, perUserGate, perUserExclusiveGate); + } + + @Override public void bindBackend(SqlUserBackend backend) { + manager.bindSharedSqlBackend(backend, lifecycleGate, userGate, exclusiveUserGate); + } + + @Override public boolean isCached(UUID uuid) { return false; } + @Override public DataValue getIfPresent(UUID uuid, String key) { return null; } + @Override public void populate(UUID uuid, HashMap values) {} + @Override public void queueChange(UUID uuid, String key, DataValue value) {} + @Override public void flush(UUID uuid, SqlUserStorage storage) {} + @Override public Set cachedUsers() { return Set.of(); } + @Override public void remove(UUID uuid) {} + @Override public void clearAfterFlush() {} + @Override public void shutdown() {} + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java new file mode 100644 index 0000000000..42e0e2ee45 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java @@ -0,0 +1,135 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +class UserDataCachePopulationRaceTest { + @Test + void retiredCacheMakesAnInFlightLegacyLoadANoop() throws Exception { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserData data = mock(UserData.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + UUID uuid = UUID.randomUUID(); + CountDownLatch storageRead = new CountDownLatch(1); + CountDownLatch releaseRead = new CountDownLatch(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + when(manager.getKeys()).thenReturn(new ArrayList<>()); + when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenAnswer(ignored -> { + storageRead.countDown(); + if (!releaseRead.await(5, TimeUnit.SECONDS)) throw new AssertionError("timed out waiting to retire cache"); + return new HashMap<>(); + }); + UserDataCache cache = new UserDataCache(manager, uuid); + AtomicReference failure = new AtomicReference<>(); + Thread loader = new Thread(() -> { + try { + cache.cache(); + } catch (Throwable thrown) { + failure.set(thrown); + } + }); + loader.start(); + try { + org.junit.jupiter.api.Assertions.assertTrue(storageRead.await(5, TimeUnit.SECONDS)); + cache.dump(); + releaseRead.countDown(); + loader.join(5000); + assertDoesNotThrow(() -> { + if (loader.isAlive()) throw new AssertionError("cache load did not finish"); + }); + org.junit.jupiter.api.Assertions.assertNull(failure.get()); + } finally { + releaseRead.countDown(); + loader.join(5000); + timer.shutdownNow(); + } + } + + @Test + void storageRefreshCannotOverwriteANewerQueuedCacheValue() { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + HashMap initial = new HashMap<>(); + initial.put("Points", new DataValueInt(1)); + cache.updateCache(initial); + cache.addChange(new UserDataChangeInt("Points", 7), true); + HashMap staleStorage = new HashMap<>(); + staleStorage.put("Points", new DataValueInt(1)); + cache.updateCachePreservingPending(staleStorage); + assertEquals(7, cache.getCache().get("Points").getInt()); + timer.shutdownNow(); + } + + @Test + void unboundMutationVersionPreventsAnOlderRefreshFromWinning() { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + HashMap initial = new HashMap<>(); + initial.put("Points", new DataValueInt(1)); + cache.updateCache(initial); + long token = cache.getSharedSnapshotVersion(); + cache.addChange(new UserDataChangeInt("Points", 7), false); + HashMap staleStorage = new HashMap<>(); + staleStorage.put("Points", new DataValueInt(1)); + cache.updateSharedSnapshot(staleStorage, token); + assertEquals(7, cache.getCache().get("Points").getInt()); + timer.shutdownNow(); + } + + @Test + void directMutationAtThePopulationTokenIsStillPreserved() { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + HashMap initial = new HashMap<>(); + initial.put("Points", new DataValueInt(1)); + cache.updateCache(initial); + cache.addChange(new UserDataChangeInt("Points", 7), false); + long tokenAfterDirectMutation = cache.getSharedSnapshotVersion(); + HashMap staleStorage = new HashMap<>(); + staleStorage.put("Points", new DataValueInt(1)); + cache.updateSharedSnapshot(staleStorage, tokenAfterDirectMutation); + assertEquals(7, cache.getCache().get("Points").getInt()); + timer.shutdownNow(); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java index 838513c60e..5d689e3906 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java @@ -194,6 +194,13 @@ public void updateCacheDefensivelyCopiesInput() { values.clear(); assertTrue(cache.isCached("PlayerName")); + assertTrue(cache.hasPublishedStorageSnapshot()); + } + + @Test + public void emptyPlaceholderIsNotACompletedStorageSnapshot() { + UserDataCache cache = new UserDataCache(mock(UserDataManager.class), UUID.randomUUID()); + assertFalse(cache.hasPublishedStorageSnapshot()); } @Test diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java index 4320113687..b8b8864966 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java @@ -7,11 +7,18 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.data.DataValue; @@ -81,4 +88,38 @@ public void testConvert_mapsColumnNameToValue() { assertSame(v1, out.get("a")); assertSame(v2, out.get("b")); } + + @Test + public void incompleteSharedCacheNeverMasqueradesAsPersistedDefaults() { + UUID uuid = UUID.randomUUID(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + UserDataCache placeholder = mock(UserDataCache.class); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); + when(user.getCache()).thenReturn(placeholder); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getDataManager()).thenReturn(manager); + when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + when(manager.effectiveStorageType(UserStorage.SQLITE)).thenReturn(UserStorage.SQLITE); + when(manager.mustDeferSharedStorageAccess()).thenReturn(true); + when(placeholder.hasPublishedStorageSnapshot()).thenReturn(false); + + UserData data = new UserData(user); + assertThrows(IllegalStateException.class, + () -> data.getInt("Points", 17, UserDataFetchMode.DEFAULT)); + assertThrows(IllegalStateException.class, + () -> data.getString("PlayerName", UserDataFetchMode.DEFAULT)); + assertThrows(IllegalStateException.class, data::getValues); + assertThrows(IllegalStateException.class, data::hasData); + + when(placeholder.hasPublishedStorageSnapshot()).thenReturn(true); + assertEquals(17, data.getInt("Points", 17, UserDataFetchMode.DEFAULT)); + assertEquals("", data.getString("PlayerName", UserDataFetchMode.DEFAULT)); + assertThrows(IllegalStateException.class, + () -> data.getInt("Points", 17, UserDataFetchMode.NO_CACHE)); + } }