From 29a4c28f34e90a1476897533328055673cef811d Mon Sep 17 00:00:00 2001 From: Ben Date: Tue, 3 Nov 2026 05:05:30 -0600 Subject: [PATCH 1/2] Add shared user-data runtime and guarded cache persistence --- AdvancedCore/pom.xml | 2 +- .../advancedcore/AdvancedCorePlugin.java | 22 + .../advancedcore/api/rewards/Reward.java | 31 +- .../api/rewards/injected/RewardInject.java | 10 + .../api/user/AdvancedCoreUser.java | 4 + .../advancedcore/api/user/UserData.java | 261 +- .../api/user/usercache/UserDataCache.java | 1221 ++++++++- .../api/user/usercache/UserDataManager.java | 1459 +++++++++- .../api/user/userstorage/mysql/MySQL.java | 149 +- .../api/user/userstorage/sql/UserTable.java | 262 +- .../bukkit/runtime/BukkitRuntimePlatform.java | 8 +- .../user/runtime/BukkitUserCacheOwner.java | 29 +- .../user/storage/BukkitSqlUserBackend.java | 19 +- .../user/storage/BukkitSqlUserStorage.java | 86 +- .../user/runtime/SharedUserDataRuntime.java | 8 +- .../core/user/runtime/UserCacheOwner.java | 10 + .../core/user/storage/SqlUserStorage.java | 8 + .../user/storage/sql/MysqlUserBackend.java | 2 + .../core/user/storage/sql/SqlUserBackend.java | 6 + .../user/storage/sql/SqliteUserBackend.java | 2 + .../rewards/RewardAsyncInjectionTest.java | 117 + .../BukkitSqlFailurePropagationTest.java | 838 +++++- .../storage/SqliteUserTransactionTest.java | 147 + .../tests/user/AdvancedCoreUserTest.java | 15 + .../tests/user/AtomicIntegerMutationTest.java | 429 ++- .../SharedCacheBindingRegressionTest.java | 2076 +++++++++++++- .../user/SharedCachePopulationRaceTest.java | 16 + .../SharedUserStorageReloadSafetyTest.java | 83 + .../advancedcore/tests/user/UserDataTest.java | 3 + .../user/UserTablePreparedStatementTest.java | 2427 +++++++++++++++++ 30 files changed, 9497 insertions(+), 253 deletions(-) diff --git a/AdvancedCore/pom.xml b/AdvancedCore/pom.xml index 3616f10ec7..4dd59d0430 100644 --- a/AdvancedCore/pom.xml +++ b/AdvancedCore/pom.xml @@ -392,7 +392,7 @@ org.xerial sqlite-jdbc 3.53.4.0 - test + provided com.nickuc.login diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 0119155b7d..5746572831 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -624,6 +624,10 @@ public CompletionStage convertDataStorageAsync(UserStorage from, UserStora } private void convertDataStorageNow(UserStorage from, UserStorage to) { + withLegacyIntegerOwnerReplacement(() -> convertDataStorageNowAdmitted(from, to)); + } + + private void convertDataStorageNowAdmitted(UserStorage from, UserStorage to) { debug("Starting convert process"); if (to == null) { throw new RuntimeException("Invalid Storage Method"); @@ -1196,6 +1200,10 @@ public void updateReplacements() { * @param storageType the storage type to load */ public void loadUserAPI(UserStorage storageType) { + withLegacyIntegerOwnerReplacement(() -> loadUserAPIAdmitted(storageType)); + } + + private void loadUserAPIAdmitted(UserStorage storageType) { if (storageType == null) { throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); } @@ -1774,6 +1782,10 @@ private UserStorageReplacement prepareUserStorageReplacement(UserStorage storage } private void installUserStorageReplacement(UserStorageReplacement replacement) { + withLegacyIntegerOwnerReplacement(() -> installUserStorageReplacementAdmitted(replacement)); + } + + private void installUserStorageReplacementAdmitted(UserStorageReplacement replacement) { retryPendingNativeUserStorageCloses(); MySQL previousMysql = mysql; Database previousDatabase = database; @@ -1926,7 +1938,17 @@ public void setConfigData(YMLConfig ymlConfig) { * * @param mysql the mysql connection to set */ + private void withLegacyIntegerOwnerReplacement(Runnable action) { + UserManager loaded = getLoadedUserManager(); + if (loaded == null || loaded.getDataManager() == null) { action.run(); return; } + loaded.getDataManager().withLegacyIntegerOwnerReplacement(action); + } + public void setMysql(MySQL mysql) { + withLegacyIntegerOwnerReplacement(() -> setMysqlAdmitted(mysql)); + } + + private void setMysqlAdmitted(MySQL mysql) { if (this.mysql != null) { this.mysql.close(); this.mysql = null; 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 2ef7d050c3..22cac38afb 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 @@ -366,7 +366,7 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, final String injectionKey = replayKey + "/" + index; if (index < resumeAfter) { sequence = sequence.thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId, - injectionKey)); + injectionKey, replayState, placeholders)); continue; } sequence = sequence.thenCompose(ignored -> { @@ -383,7 +383,7 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, replayState.setRegistryFingerprint(replayKey, registryFingerprint); return replayState.persistCheckpointAsync(plugin, placeholders) .thenCompose(ignored -> notifyReplayCheckpointPersisted(inject, user, occurrenceId, - injectionKey)) + injectionKey, replayState, placeholders)) .thenApply(ignored -> result); }).thenCompose(ignored -> resumeOnServerThread(user)); } @@ -397,11 +397,13 @@ private CompletionStage giveInjectedRewardsAsync(AdvancedCoreUser user, } private CompletionStage notifyReplayCheckpointPersisted(RewardInject inject, AdvancedCoreUser user, - String occurrenceId, - String injectionKey) { + String occurrenceId, String injectionKey, ReplayState replayState, HashMap placeholders) { try { + String selectionKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() + .encodeToString(injectionKey.getBytes(StandardCharsets.UTF_8)); + boolean selectionPresent = replaySelectionMetadata(placeholders, replayState, selectionKey) != null; CompletionStage notification = inject.onReplayCheckpointPersisted(this, user, occurrenceId, - injectionKey); + injectionKey, selectionPresent); return notification == null ? CompletableFuture.failedFuture(new IllegalStateException( "Reward injection returned a null replay-checkpoint result: " + inject.getPath())) : notification; } catch (Throwable failure) { @@ -593,7 +595,7 @@ public static String replaySelection(HashMap placeholders, Strin String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() .encodeToString(scopedKey.getBytes(StandardCharsets.UTF_8)); ReplayState replayState = ACTIVE_REPLAY_STATE.get(); - String stored = replayMetadata(placeholders, replayState, storageKey); + String stored = replaySelectionMetadata(placeholders, replayState, storageKey); if (stored != null) { if (replayState != null) replayState.recordReplayMetadata(storageKey, stored); return stored.isEmpty() ? null : new String(Base64.getUrlDecoder().decode(stored), StandardCharsets.UTF_8); @@ -611,7 +613,16 @@ public static boolean hasReplaySelection(HashMap placeholders) { if (activeKey == null) return false; String storageKey = REPLAY_SELECTION_PREFIX + Base64.getUrlEncoder().withoutPadding() .encodeToString(activeKey.getBytes(StandardCharsets.UTF_8)); - return replayMetadata(placeholders, ACTIVE_REPLAY_STATE.get(), storageKey) != null; + return replaySelectionMetadata(placeholders, ACTIVE_REPLAY_STATE.get(), storageKey) != null; + } + + private static String replaySelectionMetadata(HashMap placeholders, ReplayState replayState, String key) { + // A fresh execution can have a checkpoint writer, but that alone does not + // make caller-supplied selection metadata authoritative. Newly selected + // values are held in this execution's replay state; only restored state + // can accept persisted selections from incoming placeholders. + if (replayState != null && !replayState.restoredCheckpoint) return replayState.replayMetadata(key); + return replayMetadata(placeholders, replayState, key); } /** Durably records replay metadata before dispatching its selected side effect. */ @@ -1147,6 +1158,8 @@ public synchronized void recordReplayMetadata(String key, String value) { if (isReplayMetadataKey(key)) replayMetadata.put(key, value); } public synchronized void mergeReplayMetadataInto(HashMap target) { + if (!restoredCheckpoint) target.keySet().removeIf(key -> + key != null && key.startsWith(REPLAY_SELECTION_PREFIX) && !replayMetadata.containsKey(key)); Reward.mergeReplayMetadata(target, replayMetadata); } public synchronized String replayMetadata(String key) { return replayMetadata.get(key); } @@ -1211,7 +1224,9 @@ private CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, Consumer consumer; synchronized (this) { consumer = checkpointConsumer; } if (consumer == null) return CompletableFuture.completedFuture(null); - ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), placeholders); + HashMap checkpointPlaceholders = new HashMap<>(placeholders); + mergeReplayMetadataInto(checkpointPlaceholders); + ReplayCheckpoint checkpoint = new ReplayCheckpoint(copyProgress(), copyRegistryFingerprints(), checkpointPlaceholders); CompletableFuture persisted = new CompletableFuture<>(); AtomicBoolean claimed = new AtomicBoolean(); try { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java index 9c43766adf..dd712fd862 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/injected/RewardInject.java @@ -180,6 +180,16 @@ public CompletionStage onReplayCheckpointPersisted(Reward reward, Advanced return CompletableFuture.completedFuture(null); } + /** + * Notifies an injection with the authoritative selection state for this exact + * replay path. The default preserves existing four-argument overrides. + * A false value means no accepted replay selection exists for this injection. + */ + public CompletionStage onReplayCheckpointPersisted(Reward reward, AdvancedCoreUser user, String occurrenceId, + String injectionKey, boolean selectionPresent) { + return onReplayCheckpointPersisted(reward, user, occurrenceId, injectionKey); + } + /** Serializes a synchronized asynchronous injection through completion, not just invocation. */ public synchronized CompletionStage runSynchronizedAsync(Supplier> request) { CompletableFuture result = new CompletableFuture<>(); 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 ad41b062f8..83e0c85d66 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 @@ -776,6 +776,10 @@ public void addOfflineRewards(Reward reward, HashMap placeholder HashMap savedPlaceholders = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); if (options != null) savedPlaceholders.putAll(options.getPlaceholders()); + // The caller may supply a separate map. Filter it against the same + // replay state after merging, before it becomes a durable queue entry. + if (options != null && options.getAsyncReplayState() != null) + options.getAsyncReplayState().mergeReplayMetadataInto(savedPlaceholders); if (options != null && options.getAsyncReplayKey() != null) savedPlaceholders.put(ASYNC_REPLAY_KEY_PLACEHOLDER, options.getAsyncReplayKey()); String queued = queuedRewardReference(reward, options) + "%placeholders%" 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 efa6245b22..71e22301b0 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 @@ -22,6 +22,8 @@ import lombok.Setter; public class UserData { + /** Subclasses must opt in before replay transactions bypass user-data overrides. */ + public boolean supportsAtomicReplayTransactions() { return getClass() == UserData.class; } @Getter @Setter private HashMap tempCache; @@ -161,11 +163,11 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo // 2) UserDataCache UserDataCache sharedReadCache = null; if (mode.allowUserCache()) { - UserDataCache cache = user.getCache(); + UserDataCache cache = cacheForRead(mode); sharedReadCache = cache; if (cache != null) { // preserve previous behavior - user.cacheIfNeeded(); + if (mode.allowStorageLookup() && !isExistingLegacyCache(cache)) user.cacheIfNeeded(); DataValue cv = cache.getIfPresent(key); if (cv != null) { @@ -181,7 +183,7 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo } } } else { - user.cache(); + if (mode.allowStorageLookup()) user.cache(); } if (!mode.allowStorageLookup()) { @@ -285,7 +287,7 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) // 2) UserDataCache UserDataCache sharedReadCache = null; if (mode.allowUserCache()) { - UserDataCache cache = user.getCache(); + UserDataCache cache = cacheForRead(mode); sharedReadCache = cache; if (cache != null) { java.util.Map.Entry cached = cache.getEntryIfPresent(key); @@ -296,7 +298,7 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) return (str != null) ? str : ""; } } else { - user.cache(); + if (mode.allowStorageLookup()) user.cache(); } if (!mode.allowStorageLookup()) { @@ -316,6 +318,42 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) return sqlData.getString(storage, key); } + private UserDataCache cacheForRead(UserDataFetchMode mode) { + if (mode.allowUserCache()) { + com.bencodez.advancedcore.api.user.UserManager users = user.getPlugin().getUserManager(); + com.bencodez.advancedcore.api.user.usercache.UserDataManager manager = users == null ? null : users.getDataManager(); + if (manager != null && !mode.allowStorageLookup()) { + if (user.getUUID() == null) return null; + try { + java.util.concurrent.ConcurrentHashMap caches = manager.getUserDataCache(); + return caches == null ? null : caches.get(java.util.UUID.fromString(user.getUUID())); + } catch (IllegalArgumentException invalidUuid) { return null; } + } + if (manager != null && !manager.hasSharedSqlBackend() && user.getUUID() != null) { + try { + java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); + java.util.concurrent.ConcurrentHashMap caches = manager.getUserDataCache(); + UserDataCache current = caches == null ? null : caches.get(uuid); + if (current != null && !current.hasPublishedStorageSnapshot()) { + if (mode.waitForCache() && !current.hasEverPublishedStorageSnapshot()) return user.getCache(); + return manager.getCacheWithoutStorage(uuid); + } + } catch (IllegalArgumentException invalidUuid) { /* Preserve the normal user cache contract. */ } + } + } + return user.getCache(); + } + + private boolean isExistingLegacyCache(UserDataCache cache) { + com.bencodez.advancedcore.api.user.UserManager users = user.getPlugin().getUserManager(); + com.bencodez.advancedcore.api.user.usercache.UserDataManager manager = users == null ? null : users.getDataManager(); + if (manager == null || manager.hasSharedSqlBackend() || user.getUUID() == null) return false; + try { + java.util.concurrent.ConcurrentHashMap caches = manager.getUserDataCache(); + return caches != null && caches.get(java.util.UUID.fromString(user.getUUID())) == cache; + } catch (IllegalArgumentException invalidUuid) { return false; } + } + private HashMap requirePublishedSnapshot(UserDataFetchMode mode, UserDataCache cache) { HashMap published = mode.allowUserCache() && cache != null ? cache.snapshotIfPublished() : null; @@ -391,6 +429,51 @@ public HashMap getValues(UserStorage storage) { return convert(sqlData.readRow(storage)); } + /** A null row is ambiguous and must not authorize defaults for absent keys. */ + public HashMap getValuesIfComplete() { + UserStorage storage = effectiveStorageType(); + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + rejectPrimaryThreadPersistedRowRead(storage); + java.util.List row = sqlData.readRow(storage); + return row == null ? null : convert(row); + } + + /** + * Whether this reader can provide strict complete legacy rows without a storage + * query. A custom reader may opt in by overriding this method and the strict + * getter; it must distinguish a successful empty row from a failed read. + * This capability does not imply atomic write ordering. + */ + public boolean supportsStrictCompleteValues() { + try { + Class type = getClass(); + if (type.getMethod("getValues").getDeclaringClass() != UserData.class + || type.getMethod("getValues", UserStorage.class).getDeclaringClass() != UserData.class + || type.getMethod("getMySqlRow").getDeclaringClass() != UserData.class + || type.getMethod("getSQLiteRow").getDeclaringClass() != UserData.class + || type.getMethod("convert", java.util.List.class).getDeclaringClass() != UserData.class + || type.getMethod("getValuesIfStrictComplete").getDeclaringClass() != UserData.class) return false; + } catch (NoSuchMethodException | SecurityException unavailable) { return false; } + return sqlStorage.supportsStrictLegacyRowReads(effectiveStorageType()); + } + + /** Strict legacy row read for atomic operations; custom row readers remain authoritative. */ + public HashMap getValuesIfStrictComplete() { + try { + Class type = getClass(); + if (type.getMethod("getValues").getDeclaringClass() != UserData.class + || type.getMethod("getValues", UserStorage.class).getDeclaringClass() != UserData.class + || type.getMethod("getMySqlRow").getDeclaringClass() != UserData.class + || type.getMethod("getSQLiteRow").getDeclaringClass() != UserData.class + || type.getMethod("convert", java.util.List.class).getDeclaringClass() != UserData.class) return null; + } catch (NoSuchMethodException unavailable) { return null; } + UserStorage storage = effectiveStorageType(); + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + rejectPrimaryThreadPersistedRowRead(storage); + java.util.List row = sqlStorage.readRowStrict(storage); + return row == null ? null : convert(row); + } + public boolean hasData() { UserStorage storage = effectiveStorageType(); ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); @@ -452,15 +535,32 @@ public void setInt(final UserStorage storage, final String key, final int value, UserDataChangeInt change = new UserDataChangeInt(key, value); if (queueSharedMutation(storage, change, queue, async)) return; ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + UserDataManager directManager = sharedDataManager(); + UserDataManager.LegacyDirectWrite early = (!queue || async) ? beginDirectAdmission(directManager, storage, change) : null; + java.util.concurrent.atomic.AtomicBoolean released = new java.util.concurrent.atomic.AtomicBoolean(); + UserDataManager.LegacyDirectWrite captured = early; + Runnable release = () -> { if (captured != null && released.compareAndSet(false, true)) directManager.endLegacyDirectWrite(captured); }; + boolean[] handedOff = { false }; + try { - if (user.isCached()) { + boolean initiallyCached = user.isCached(); + if (initiallyCached) { boolean flushImmediately = !queue && user.getPlugin().getUserManager().getDataManager() .usesSharedSqlStorage(storage); - user.getCache().addChange(change, queue || flushImmediately); + if (queue || storage == effectiveStorageType()) { + UserDataCache target = queue ? cacheForRead(UserDataFetchMode.CACHE_ONLY) : user.getCache(); + if (queue || flushImmediately || target.matchesLegacyStorage(storage)) { + if (!queue && !flushImmediately) UserDataCache.runWithLegacyDirectPublication(directManager, captured, target, () -> target.addChange(change, false)); + else target.addChange(change, true); + } + } // 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 (!flushImmediately) { + if (queue) release.run(); + UserDataCache.runWithLegacyDirectCallback(directManager, captured, () -> user.getPlugin().getUserManager().onChange(user, key)); + } if (queue || flushImmediately) { if (flushImmediately) { user.getCache().processChangesImmediately(async); @@ -470,27 +570,102 @@ public void setInt(final UserStorage storage, final String key, final int value, } if (async) { - user.getPlugin().getTimer().execute(new Runnable() { + handedOff[0] = true; + try { user.getPlugin().getTimer().execute(new Runnable() { @Override public void run() { - sqlData.setInt(storage, key, value); + try { + writeDirectInt(storage, key, value, captured); + if (captured != null) directManager.markLegacyDirectWriteSucceeded(captured); - if (!user.isCached()) { + if (!initiallyCached) { + release.run(); user.getPlugin().getUserManager().onChange(user, key); } + } catch (RuntimeException | Error failure) { if (captured != null) directManager.failLegacyDirectWrite(captured, change, failure); throw failure; } finally { release.run(); } } - }); + }); } catch (RuntimeException | Error failure) { if (captured != null) directManager.failLegacyDirectWrite(captured, change, failure); release.run(); throw failure; } } else { // process change right away - sqlData.setInt(storage, key, value); + writeDirectInt(storage, key, value, captured); + if (captured != null) directManager.markLegacyDirectWriteSucceeded(captured); - if (!user.isCached()) { + if (!initiallyCached) { + release.run(); user.getPlugin().getUserManager().onChange(user, key); } } + } catch (RuntimeException | Error failure) { if (captured != null) directManager.failLegacyDirectWrite(captured, change, failure); throw failure; } finally { if (!handedOff[0]) release.run(); } + } + + private static String directStorageKey(UserStorage storage, String key) { + return storage == UserStorage.SQLITE ? UserDataCache.sqliteDirectKey(key) : storage.name() + ":" + key; + } + + private UserDataManager.LegacyDirectWrite beginDirectAdmission(UserDataManager manager, UserStorage storage, UserDataChange change) { + if (manager == null || manager.hasSharedSqlBackend() || user.getUUID() == null) return null; + try { String row = user.getUUID(); return manager.beginLegacyDirectWrite(java.util.UUID.fromString(row), java.util.Set.of(directStorageKey(storage, change.getKey())), storage == UserStorage.SQLITE ? row : null); } + catch (IllegalArgumentException invalidUuid) { return null; } } + private void writeDirectInt(UserStorage storage, String key, int value, UserDataManager.LegacyDirectWrite alreadyAdmitted) { + writeDirect(() -> sqlData.setInt(storage, key, value), null, alreadyAdmitted, storage, new UserDataChangeInt(key, value)); + } + + private void writeDirect(Runnable operation, java.util.Map batchValues, UserDataManager.LegacyDirectWrite alreadyAdmitted, UserStorage storage, UserDataChange change) { + com.bencodez.advancedcore.api.user.UserManager users = user.getPlugin().getUserManager(); + UserDataManager manager = users == null ? null : users.getDataManager(); + UserDataManager.LegacyDirectWrite admission = null; + boolean internalBatch = false; + if (manager != null && !manager.hasSharedSqlBackend() && user.getUUID() != null) { + try { + java.lang.String rowIdentity = user.getUUID(); + java.util.UUID uuid = java.util.UUID.fromString(rowIdentity); + internalBatch = batchValues != null && UserDataCache.isLegacyBatchPersistence(manager, uuid, batchValues); + if (!internalBatch && alreadyAdmitted == null) { + java.util.Set keys = new java.util.HashSet<>(); + if (change != null) keys.add(directStorageKey(storage, change.getKey())); + else if (batchValues != null) for (String name : batchValues.keySet()) { + if (name == null || name.equals("uuid")) continue; + DataValue value = batchValues.get(name); + if (storage == UserStorage.SQLITE && UserDataCache.sqliteDirectKey(name).equals(UserDataCache.sqliteDirectKey("uuid")) + && value != null && value.isString() && user.getUUID().equals(value.getString())) continue; + keys.add(directStorageKey(storage, name)); + } + admission = manager.beginLegacyDirectWrite(uuid, keys, storage == UserStorage.SQLITE ? rowIdentity : null); + if (admission != null && storage == UserStorage.SQLITE && batchValues != null) { + DataValue first = null, last = null; boolean found = false; + for (java.util.Map.Entry entry : batchValues.entrySet()) { + String name = entry.getKey(); + if (name == null || name.equals("uuid") || !UserDataCache.sqliteDirectKey(name).equals(UserDataCache.sqliteDirectKey("uuid"))) continue; + if (!found) first = entry.getValue(); + last = entry.getValue(); found = true; + } + if (found) manager.configureSqliteBulkPrimary(admission, + first == null || !first.isString() || !user.getUUID().equals(first.getString()), + last == null || !last.isString() || !user.getUUID().equals(last.getString())); + } + } + } + catch (IllegalArgumentException invalidUuid) { /* Preserve legacy non-UUID identities. */ } + } + UserDataManager.LegacyDirectWrite effectiveAdmission = alreadyAdmitted == null ? admission : alreadyAdmitted; + if (!internalBatch && batchValues != null && manager != null) manager.invalidateLegacyBulkSnapshot(effectiveAdmission); + try { + if (!internalBatch && manager != null && storage == UserStorage.SQLITE) { + if (change != null && UserDataCache.sqliteDirectKey(change.getKey()).equals(UserDataCache.sqliteDirectKey("uuid"))) manager.guardLegacySqliteDestination(effectiveAdmission, change.toUserDataValue()); + if (batchValues != null) for (java.util.Map.Entry entry : batchValues.entrySet()) if (entry.getKey() != null && UserDataCache.sqliteDirectKey(entry.getKey()).equals(UserDataCache.sqliteDirectKey("uuid"))) manager.guardLegacySqliteDestination(effectiveAdmission, entry.getValue()); + } + if (internalBatch) operation.run(); else UserDataCache.runWithLegacyDirectWrite(manager, effectiveAdmission, change, () -> UserDataCache.runOutsideLegacyBatch(operation)); + if (!internalBatch && batchValues != null && manager != null) manager.completeLegacyBulkValues(effectiveAdmission, batchValues, key -> directStorageKey(storage, key)); + if (!internalBatch && change != null && manager != null) manager.completeLegacyBulkValues(effectiveAdmission, java.util.Collections.singletonMap(change.getKey(), change.toUserDataValue()), key -> directStorageKey(storage, key)); + if (admission != null) manager.markLegacyDirectWriteSucceeded(admission); + } + catch (RuntimeException | Error failure) { if (admission != null) manager.failLegacyDirectWrite(admission, change, failure); throw failure; } + finally { if (admission != null) manager.endLegacyDirectWrite(admission); } + } + public void setString(final String key, final String value) { setString(key, value, true); } @@ -523,12 +698,29 @@ public void setString(final UserStorage storage, final String key, final String UserDataChangeString change = new UserDataChangeString(key, value); if (queueSharedMutation(storage, change, queue, async)) return; ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + UserDataManager directManager = sharedDataManager(); + UserDataManager.LegacyDirectWrite early = (!queue || async) ? beginDirectAdmission(directManager, storage, change) : null; + java.util.concurrent.atomic.AtomicBoolean released = new java.util.concurrent.atomic.AtomicBoolean(); + UserDataManager.LegacyDirectWrite captured = early; + Runnable release = () -> { if (captured != null && released.compareAndSet(false, true)) directManager.endLegacyDirectWrite(captured); }; + boolean[] handedOff = { false }; + try { - if (user.isCached()) { + boolean initiallyCached = user.isCached(); + if (initiallyCached) { 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 || storage == effectiveStorageType()) { + UserDataCache target = queue ? cacheForRead(UserDataFetchMode.CACHE_ONLY) : user.getCache(); + if (queue || flushImmediately || target.matchesLegacyStorage(storage)) { + if (!queue && !flushImmediately) UserDataCache.runWithLegacyDirectPublication(directManager, captured, target, () -> target.addChange(change, false)); + else target.addChange(change, true); + } + } + if (!flushImmediately) { + if (queue) release.run(); + UserDataCache.runWithLegacyDirectCallback(directManager, captured, () -> user.getPlugin().getUserManager().onChange(user, key)); + } if (queue || flushImmediately) { if (flushImmediately) { user.getCache().processChangesImmediately(async); @@ -538,22 +730,30 @@ public void setString(final UserStorage storage, final String key, final String } if (async) { - user.getPlugin().getTimer().execute(new Runnable() { + handedOff[0] = true; + try { user.getPlugin().getTimer().execute(new Runnable() { @Override public void run() { - sqlData.setString(storage, key, value); - if (!user.isCached()) { + try { + writeDirect(() -> sqlData.setString(storage, key, value), null, captured, storage, change); + if (captured != null) directManager.markLegacyDirectWriteSucceeded(captured); + if (!initiallyCached) { + release.run(); user.getPlugin().getUserManager().onChange(user, key); } + } catch (RuntimeException | Error failure) { if (captured != null) directManager.failLegacyDirectWrite(captured, change, failure); throw failure; } finally { release.run(); } } - }); + }); } catch (RuntimeException | Error failure) { if (captured != null) directManager.failLegacyDirectWrite(captured, change, failure); release.run(); throw failure; } } else { - sqlData.setString(storage, key, value); - if (!user.isCached()) { + writeDirect(() -> sqlData.setString(storage, key, value), null, captured, storage, change); + if (captured != null) directManager.markLegacyDirectWriteSucceeded(captured); + if (!initiallyCached) { + release.run(); user.getPlugin().getUserManager().onChange(user, key); } } + } catch (RuntimeException | Error failure) { if (captured != null) directManager.failLegacyDirectWrite(captured, change, failure); throw failure; } finally { if (!handedOff[0]) release.run(); } } @@ -696,9 +896,16 @@ public void setStringList(final String key, final ArrayList value, boole setString(key, str, queue); } - public void setValues(HashMap values) { - setValues(effectiveStorageType(), values); - } + public void setValues(HashMap values) { + com.bencodez.advancedcore.api.user.UserManager users = user.getPlugin().getUserManager(); + UserDataManager manager = users == null ? null : users.getDataManager(); + UserStorage captured = null; + if (manager != null && user.getUUID() != null) { + try { captured = UserDataCache.legacyBatchStorage(manager, java.util.UUID.fromString(user.getUUID()), values); } + catch (IllegalArgumentException ignored) { /* Preserve ordinary non-UUID user behavior. */ } + } + setValues(captured == null ? effectiveStorageType() : captured, values); + } public void setValues(String key, DataValue value) { HashMap values = new HashMap<>(); @@ -709,7 +916,7 @@ public void setValues(String key, DataValue value) { public void setValues(UserStorage storage, HashMap values) { if (queueSharedValues(storage, values)) return; ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); - sqlData.setValues(storage, values); + writeDirect(() -> sqlData.setValues(storage, values), values, null, storage, null); } public void tempCache() { 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 d7b54061f8..1ff1a388a9 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 @@ -24,17 +24,30 @@ public class UserDataCache { @Getter private HashMap cache; private Queue cachedChanges; private final UserDataManager manager; + private com.bencodez.advancedcore.api.user.UserStorage legacyStorageType; + private com.bencodez.advancedcore.AdvancedCorePlugin.UserStorageOwner legacyStorageOwner; private long snapshotVersion; private long replacementVersion; private boolean storedDataPresent; private boolean storageSnapshotPublished; + private boolean hasEverPublishedStorageSnapshot; + private boolean hasPublishedStorageSnapshot; + private boolean completeStorageSnapshot; private final HashMap changedAt = new HashMap<>(); private final HashMap persistedAt = new HashMap<>(); + private final HashMap persistedValues = new HashMap<>(); private final HashMap inFlightValues = new HashMap<>(); + private record LegacyBulkOverride(DataValue value, long version, boolean sqlite) { } + private final HashMap legacySqliteBulkOverrides = new HashMap<>(); + private final HashMap legacyExactBulkOverrides = new HashMap<>(); + private final HashMap sqliteChangedAt = new HashMap<>(); + private final HashMap sqliteChangedValues = new HashMap<>(); + private final HashMap sqliteCacheKeys = new HashMap<>(); private boolean scheduled = false; private boolean removing; private int inFlightBatches = 0; private volatile Consumer> sharedStorageWriter; + private com.bencodez.advancedcore.api.user.UserStorage sharedStorageType; private Thread sharedBatchThread; private volatile Consumer sharedFlushGate; private volatile Consumer sharedExclusiveFlushGate; @@ -43,14 +56,391 @@ public class UserDataCache { private final Queue notificationsAfterExclusiveFlush = new ConcurrentLinkedQueue<>(); private boolean flushChangesAfterExclusive; private final ReentrantReadWriteLock legacyMutationOrder = new ReentrantReadWriteLock(true); + private int legacyCheckpointsPending; + private record LegacyBatchContext(UserDataManager manager, UUID uuid, Object values, UserDataManager.LegacyRead read, + com.bencodez.advancedcore.api.user.UserStorage storage, java.util.concurrent.atomic.AtomicReference delegatedFailure) { } + private static final ThreadLocal LEGACY_BATCH_PERSISTENCE = new ThreadLocal<>(); + private static final ThreadLocal LEGACY_DELEGATED_BATCH = new ThreadLocal<>(); + /** Opaque identity for a currently executing legacy batch. It is not transferable between threads. */ + public static final class LegacyBatchToken { + private final LegacyBatchContext context; + private final Thread owner; + private LegacyBatchToken(LegacyBatchContext context) { this.context = context; this.owner = Thread.currentThread(); } + } + + /** + * Capture the active batch identity when an override receives its original map. + * Returns null for ordinary independent calls. Overrides which copy a batch map + * may pass the returned token to {@link #withLegacyBatchCopy} while delegating. + */ + public static LegacyBatchToken captureLegacyBatchToken(HashMap originalValues) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + return current != null && current.values() == originalValues ? new LegacyBatchToken(current) : null; + } + + /** + * Explicitly delegate a copy within the same synchronous invocation. The token + * carries the original destination and generation; independent nested writes + * must not use it. An expired or cross-thread token is rejected. + */ + public static void withLegacyBatchCopy(LegacyBatchToken token, HashMap copiedValues, Runnable delegate) { + java.util.Objects.requireNonNull(token, "token"); + java.util.Objects.requireNonNull(copiedValues, "copiedValues"); + java.util.Objects.requireNonNull(delegate, "delegate"); + LegacyBatchContext prior = LEGACY_BATCH_PERSISTENCE.get(); + if (token.owner != Thread.currentThread() || prior != token.context) throw new IllegalStateException("Legacy batch token is not active on this thread"); + LEGACY_BATCH_PERSISTENCE.set(new LegacyBatchContext(prior.manager(), prior.uuid(), copiedValues, prior.read(), prior.storage(), prior.delegatedFailure())); + try { delegate.run(); } + finally { LEGACY_BATCH_PERSISTENCE.set(prior); } + } + private static final class LegacyDirectContext { + final UserDataManager manager; + UserDataManager.LegacyDirectWrite admission; + final UserDataChange change; + final java.util.Set sqliteObserved = new java.util.HashSet<>(); + boolean sqliteExecuting; + Object sqliteProvider; + String sqliteMoveDestination; + LegacyDirectContext(UserDataManager manager, UserDataManager.LegacyDirectWrite admission, UserDataChange change) { + this.manager = manager; this.admission = admission; this.change = change; + } + } + private static final ThreadLocal LEGACY_DIRECT_PERSISTENCE = new ThreadLocal<>(); + private record LegacyCallback(UserDataManager manager, UserDataManager.LegacyDirectWrite admission, LegacyCallback previous) { } + private static final ThreadLocal LEGACY_DIRECT_CALLBACK = new ThreadLocal<>(); + private record LegacyPublication(UserDataManager manager, UserDataManager.LegacyDirectWrite admission, UserDataCache cache) { } + static final class DirectCachePublication { + final HashMap previousValues = new HashMap<>(); + final HashMap previousVersions = new HashMap<>(); + final java.util.Set keys = new java.util.HashSet<>(); + String sqliteKey, previousSqliteAlias; + Long previousSqliteVersion; + DataValue previousSqliteValue; + } + private DirectCachePublication captureDirectCachePublication(String key, String mysqlAlias) { + DirectCachePublication previous = new DirectCachePublication(); + previous.keys.add(key); + for (String candidate : cache.keySet()) if (candidate != null && (candidate.equals(key) + || (mysqlAlias != null && candidate.equalsIgnoreCase(key)) + || (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE && sqliteDirectKey(candidate).equals(sqliteDirectKey(key))))) previous.keys.add(candidate); + for (String candidate : previous.keys) { + if (cache.containsKey(candidate)) previous.previousValues.put(candidate, cache.get(candidate)); + if (changedAt.containsKey(candidate)) previous.previousVersions.put(candidate, changedAt.get(candidate)); + } + previous.sqliteKey = sqliteDirectKey(key); + previous.previousSqliteVersion = sqliteChangedAt.get(previous.sqliteKey); + previous.previousSqliteValue = sqliteChangedValues.get(previous.sqliteKey); + previous.previousSqliteAlias = sqliteCacheKeys.get(previous.sqliteKey); + return previous; + } + synchronized void restoreRedirectedPublication(DirectCachePublication previous, long version) { + if (previous == null || cache == null) return; + for (String key : previous.keys) if (java.util.Objects.equals(changedAt.get(key), version)) { + if (previous.previousValues.containsKey(key)) cache.put(key, previous.previousValues.get(key)); else cache.remove(key); + if (previous.previousVersions.containsKey(key)) changedAt.put(key, previous.previousVersions.get(key)); else changedAt.remove(key); + } + if (java.util.Objects.equals(sqliteChangedAt.get(previous.sqliteKey), version)) { + if (previous.previousSqliteVersion == null) sqliteChangedAt.remove(previous.sqliteKey); else sqliteChangedAt.put(previous.sqliteKey, previous.previousSqliteVersion); + if (previous.previousSqliteValue == null) sqliteChangedValues.remove(previous.sqliteKey); else sqliteChangedValues.put(previous.sqliteKey, previous.previousSqliteValue); + if (previous.previousSqliteAlias == null) sqliteCacheKeys.remove(previous.sqliteKey); else sqliteCacheKeys.put(previous.sqliteKey, previous.previousSqliteAlias); + } + } + private static final ThreadLocal LEGACY_DIRECT_PUBLICATION = new ThreadLocal<>(); + public static void runWithLegacyDirectPublication(UserDataManager manager, UserDataManager.LegacyDirectWrite admission, UserDataCache cache, Runnable publication) { + if (manager == null || admission == null || cache == null) { publication.run(); return; } + if (!manager.matchesLegacyDirectCacheRow(admission, cache)) return; + LegacyPublication previous = LEGACY_DIRECT_PUBLICATION.get(); + LEGACY_DIRECT_PUBLICATION.set(new LegacyPublication(manager, admission, cache)); + try { publication.run(); } + finally { if (previous == null) LEGACY_DIRECT_PUBLICATION.remove(); else LEGACY_DIRECT_PUBLICATION.set(previous); } + } + public static void runWithLegacyDirectCallback(UserDataManager manager, UserDataManager.LegacyDirectWrite admission, Runnable notification) { + if (manager == null || admission == null) { notification.run(); return; } + LegacyCallback previous = LEGACY_DIRECT_CALLBACK.get(); + LEGACY_DIRECT_CALLBACK.set(new LegacyCallback(manager, admission, previous)); + try { notification.run(); } + finally { if (previous == null) LEGACY_DIRECT_CALLBACK.remove(); else LEGACY_DIRECT_CALLBACK.set(previous); } + } + static UserDataManager.LegacyDirectWrite currentLegacyCallback(UserDataManager manager) { + LegacyCallback callback = LEGACY_DIRECT_CALLBACK.get(); + return callback != null && callback.manager() == manager ? callback.admission() : null; + } + static boolean hasLiveLegacyCallback(UserDataManager manager) { + for (LegacyCallback callback = LEGACY_DIRECT_CALLBACK.get(); callback != null; callback = callback.previous()) { + if (callback.manager() == manager && manager.isLiveLegacyDirectWrite(callback.admission())) return true; + } + return false; + } + public static void runWithLegacyDirectWrite(UserDataManager manager, UserDataManager.LegacyDirectWrite admission, UserDataChange change, Runnable operation) { + LegacyDirectContext previous = LEGACY_DIRECT_PERSISTENCE.get(); + if (manager != null && admission != null) LEGACY_DIRECT_PERSISTENCE.set(new LegacyDirectContext(manager, admission, change)); + else LEGACY_DIRECT_PERSISTENCE.remove(); + try { operation.run(); } + finally { if (previous == null) LEGACY_DIRECT_PERSISTENCE.remove(); else LEGACY_DIRECT_PERSISTENCE.set(previous); } + } + public static boolean hasLegacyDirectPersistence() { return LEGACY_DIRECT_PERSISTENCE.get() != null; } + public static com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL capturedNestedMysql() { + LegacyDirectContext current = LEGACY_DIRECT_PERSISTENCE.get(); + return current == null ? null : current.manager.capturedNestedMysql(current.admission); + } + public static com.bencodez.advancedcore.api.user.userstorage.sql.UserTable capturedNestedSqlite() { + LegacyDirectContext current = LEGACY_DIRECT_PERSISTENCE.get(); + return current == null ? null : current.manager.capturedNestedSqlite(current.admission); + } + public static boolean hasLegacyDirectPersistence(String row) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context == null || row == null) return false; + try { return context.manager.isLegacyDirectIdentity(context.admission, row); } + catch (IllegalArgumentException invalidUuid) { return false; } + } + /** Bind a previously admitted direct operation to the row actually selected by its provider. */ + public static void bindLegacyDirectRow(String row, boolean sqlite) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context == null || row == null) return; + try { context.admission = context.manager.bindLegacyDirectRow(context.admission, row, sqlite); } + catch (IllegalArgumentException invalidUuid) { /* Preserve legacy non-UUID identities. */ } + } + /** SQLite folds ASCII identifier case, but does not fold non-ASCII identifiers. */ + public static String sqliteDirectKey(String key) { + StringBuilder result = new StringBuilder("SQLITE:"); + for (int i = 0; i < key.length(); i++) { char c = key.charAt(i); result.append(c >= 'A' && c <= 'Z' ? (char) (c + ('a' - 'A')) : c); } + return result.toString(); + } + /** Retain a provider-reported failure without changing the legacy caller's exception contract. */ + public static void recordLegacyDirectWriteFailure(String uuid, Throwable failure) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context == null || uuid == null) return; + try { if (context.manager.isLegacyDirectIdentity(context.admission, uuid)) { + LegacyBatchContext delegated = LEGACY_DELEGATED_BATCH.get(); + if (delegated != null && context.change == null && delegated.manager() == context.manager + && context.manager.isRelatedLegacyBatchFailure(context.admission, delegated.uuid(), delegated.storage(), delegated.values())) + delegated.delegatedFailure().compareAndSet(null, failure); + context.manager.rollbackSqliteDirectCommit(context.admission); + context.manager.failLegacyDirectWrite(context.admission, context.change, failure); + } } + catch (IllegalArgumentException invalidUuid) { /* Preserve legacy non-UUID identities. */ } + } + public static boolean isCurrentLegacyDirectKey(String uuid, String key) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context == null) return true; + try { return context.manager.isCurrentLegacyDirectKey(context.admission, uuid, sqliteDirectKey(key)); } + catch (IllegalArgumentException invalidUuid) { return true; } + } + public static boolean isCurrentLegacyProviderKey(String uuid, String key, com.bencodez.advancedcore.api.user.UserStorage storage, boolean foldColumns) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context == null || uuid == null || key == null || storage == null) return true; + try { return storage == com.bencodez.advancedcore.api.user.UserStorage.MYSQL + ? context.manager.isCurrentMysqlDirectKey(context.admission, uuid, key, foldColumns) + : context.manager.isCurrentLegacyDirectKey(context.admission, uuid, storage.name() + ":" + key); } + catch (IllegalArgumentException invalidUuid) { return true; } + } + public static void recordLegacyProviderCommit(String uuid, java.util.Collection keys, com.bencodez.advancedcore.api.user.UserStorage storage, boolean foldColumns) { + recordLegacyProviderCommit(uuid, keys, storage, foldColumns, null); + } + public static void recordLegacyProviderCommit(String uuid, java.util.Collection keys, com.bencodez.advancedcore.api.user.UserStorage storage, boolean foldColumns, Object provider) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context == null || uuid == null || storage == null) return; + try { + if (!context.manager.isLegacyDirectIdentity(context.admission, uuid)) return; + java.util.HashSet identities = new java.util.HashSet(); + for (String key : keys) if (key != null) identities.add(storage.name() + ":" + key); + context.manager.recordLegacyProviderCommit(context.admission, identities, foldColumns, provider); + } catch (IllegalArgumentException invalidUuid) { /* Preserve non-UUID behavior. */ } + } + /** Record only keys actually admitted by SQLite during the guarded statement. */ + public static boolean observeSqliteDirectKey(String uuid, String key) { + boolean current = isCurrentLegacyDirectKey(uuid, key); + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (current && context != null && context.sqliteExecuting) context.sqliteObserved.add(sqliteDirectKey(key)); + return current; + } + public static void beginSqliteDirectExecution(boolean insertStatement) { + beginSqliteDirectExecution(insertStatement, null); + } + public static void beginSqliteDirectExecution(boolean insertStatement, Object provider) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context != null) { context.sqliteObserved.clear(); context.sqliteExecuting = true; context.sqliteProvider = provider; context.manager.recordSqliteDirectStatement(context.admission, insertStatement); } + } + public static void setSqliteMoveDestination(String source, String destination) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context != null) context.sqliteMoveDestination = destination != null && !destination.equals(source) ? destination : null; + } + public static void classifySqliteDirectStatement(boolean insertStatement) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context != null) context.manager.recordSqliteDirectStatement(context.admission, insertStatement); + } + public static boolean hasAdmittedSqliteDirectKey(String key) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + return context != null && context.manager.hasLegacyDirectKey(context.admission, sqliteDirectKey(key)); + } + public static void endSqliteDirectExecution() { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context != null) context.sqliteExecuting = false; + } + public static void sqliteDirectCommit() { + sqliteDirectCommit(null); + } + public static void sqliteDirectCommit(Object provider) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context != null && context.sqliteExecuting && (context.sqliteProvider == null || context.sqliteProvider == provider)) { + context.manager.recordSqliteDirectCommit(context.admission, context.sqliteObserved, provider); + if (context.sqliteObserved.contains(sqliteDirectKey("uuid"))) context.manager.recordSqliteDestinationCommit(context.admission, context.sqliteMoveDestination); + } + } + public static void sqliteDirectRollback() { + sqliteDirectRollback(null); + } + public static void sqliteDirectRollback(Object provider) { + LegacyDirectContext context = LEGACY_DIRECT_PERSISTENCE.get(); + if (context != null && context.sqliteExecuting && (context.sqliteProvider == null || context.sqliteProvider == provider)) context.manager.rollbackSqliteDirectCommit(context.admission); + } + public static final class StaleLegacyBatchException extends IllegalStateException { + private StaleLegacyBatchException() { super("Legacy batch was superseded by a direct write"); } + } + /** + * Participating providers call this under the same storage admission used by + * direct writes, immediately before the physical batch write. A check without + * that shared admission cannot prevent a later concurrent stale write. Opaque + * legacy overrides retain their existing behavior and do not gain this guarantee. + */ + public static void rejectStaleLegacyBatch(String uuid) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + if (current != null && current.uuid().toString().equalsIgnoreCase(uuid) + && !current.manager().isCurrentLegacyRead(current.read())) throw new StaleLegacyBatchException(); + } + public static boolean hasLegacyBatchPersistence(String uuid) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + return current != null && current.uuid().toString().equalsIgnoreCase(uuid); + } + public static long confirmedLegacyBatchClaimSerial(String uuid) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + return current != null && uuid != null && current.uuid().toString().equals(uuid) + ? current.manager().confirmedNestedClaimSerial(current.read()) : 0; + } + public static void recordLegacyBatchProviderCommit(String uuid, java.util.Collection keys, com.bencodez.advancedcore.api.user.UserStorage storage, boolean foldColumns) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + if (current == null || storage == null || current.storage() != storage || uuid == null || !current.uuid().toString().equalsIgnoreCase(uuid)) return; + current.manager().recordCompletedLegacyCallbackBatch(current.read(), uuid, new java.util.HashSet<>(keys), storage, foldColumns); + } + public static void rejectLegacyBatchInsertConflict(String uuid) { + if (hasLegacyBatchPersistence(uuid)) throw new StaleLegacyBatchException(); + } + public static void propagateLegacyBatchWriteFailure(String uuid, Throwable failure) { + if (hasLegacyBatchPersistence(uuid)) { + if (failure instanceof StaleLegacyBatchException stale) throw stale; + throw new IllegalStateException("Legacy cache batch storage failed", failure); + } + } + public static boolean isLegacyBatchPersistence(UserDataManager manager, UUID uuid, Object values) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + return current != null && current.manager() == manager && current.uuid().equals(uuid) && current.values() == values; + } + static boolean hasActiveLegacyBatch(UserDataManager manager, UUID uuid) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + return current != null && current.manager() == manager && current.uuid().equals(uuid); + } + static UserDataManager.LegacyRead overlappingNestedRead(UserDataManager manager, UUID uuid, java.util.Set keys) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + if (current == null || current.manager() != manager || !current.uuid().equals(uuid) + || current.storage() == null || keys.size() != 1 + || !(current.values() instanceof java.util.Map values)) return null; + String key = keys.iterator().next(); + if (key == null || !key.startsWith(current.storage().name() + ":")) return null; + String name = key.substring(current.storage().name().length() + 1); + if (name.equalsIgnoreCase("uuid")) return null; + for (Object raw : values.keySet()) if (raw instanceof String valueKey && (current.storage() == com.bencodez.advancedcore.api.user.UserStorage.SQLITE + ? sqliteDirectKey(valueKey).equals(key) : valueKey.equals(name) + || (manager.capturedMysqlFoldsColumnNames(current.read()) && valueKey.chars().allMatch(c -> c < 128) + && name.chars().allMatch(c -> c < 128) && valueKey.equalsIgnoreCase(name)))) return current.read(); + return null; + } + /** Participating MySQL providers call this while holding their physical write admission. */ + public static java.util.List filterConfirmedLegacyBatchColumns(String uuid, + java.util.List columns) { + return filterConfirmedLegacyBatchColumns(uuid, columns, com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + } + public static java.util.List filterConfirmedLegacyBatchColumns(String uuid, + java.util.List columns, Object provider) { + return filterConfirmedLegacyBatchColumns(uuid, columns, com.bencodez.advancedcore.api.user.UserStorage.MYSQL, provider); + } + /** SQLite calls this only under its existing-row provider admission. */ + public static java.util.List filterConfirmedLegacyBatchColumns(String uuid, + java.util.List columns, com.bencodez.advancedcore.api.user.UserStorage storage) { + return filterConfirmedLegacyBatchColumns(uuid, columns, storage, null); + } + public static java.util.List filterConfirmedLegacyBatchColumns(String uuid, + java.util.List columns, com.bencodez.advancedcore.api.user.UserStorage storage, Object provider) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + if (current == null || uuid == null || current.storage() != storage + || (storage == com.bencodez.advancedcore.api.user.UserStorage.SQLITE ? !current.uuid().toString().equals(uuid) : !current.uuid().toString().equalsIgnoreCase(uuid))) return columns; + java.util.Set excluded = current.manager().claimConfirmedNestedKeys(current.read(), provider); + if (excluded.isEmpty()) return columns; + java.util.ArrayList result = new java.util.ArrayList(); + for (com.bencodez.simpleapi.sql.Column column : columns) if (!excluded.contains(storage == com.bencodez.advancedcore.api.user.UserStorage.SQLITE + ? sqliteDirectKey(column.getName()) : "MYSQL:" + column.getName()) + && !(storage == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && column.getName().chars().allMatch(c -> c < 128) + && excluded.contains("MYSQLF:" + column.getName().toLowerCase(java.util.Locale.ROOT)))) result.add(column); + return result; + } + /** Return the enclosing batch only for clearly disjoint ASCII column identities. */ + static UserDataManager.LegacyRead disjointNestedLegacyRead(UserDataManager manager, UUID uuid, java.util.Set keys) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + if (current == null || current.manager() != manager || !current.uuid().equals(uuid) + || current.storage() == null || keys.isEmpty() || !(current.values() instanceof java.util.Map values)) return null; + for (String key : keys) { + if (key == null || !key.startsWith(current.storage().name() + ":")) return null; + String column = key.substring(current.storage().name().length() + 1); + if (column.isEmpty() || column.equalsIgnoreCase("uuid") || !column.chars().allMatch(c -> c < 128)) return null; + for (Object valueKey : values.keySet()) { + if (!(valueKey instanceof String name) || !name.chars().allMatch(c -> c < 128) || name.equalsIgnoreCase(column)) return null; + } + } + return current.read(); + } + public static com.bencodez.advancedcore.api.user.UserStorage legacyBatchStorage(UserDataManager manager, UUID uuid, Object values) { + LegacyBatchContext current = LEGACY_BATCH_PERSISTENCE.get(); + return isLegacyBatchPersistence(manager, uuid, values) ? current.storage() : null; + } + public static void runOutsideLegacyBatch(Runnable operation) { + LegacyBatchContext previous = LEGACY_BATCH_PERSISTENCE.get(); + if (previous == null) { operation.run(); return; } + LegacyBatchContext previousDelegated = LEGACY_DELEGATED_BATCH.get(); + LEGACY_DELEGATED_BATCH.set(previous); + LEGACY_BATCH_PERSISTENCE.remove(); + try { operation.run(); } + finally { + LEGACY_BATCH_PERSISTENCE.set(previous); + if (previousDelegated == null) LEGACY_DELEGATED_BATCH.remove(); else LEGACY_DELEGATED_BATCH.set(previousDelegated); + } + } + private int legacyDirectWrites; + private final Queue legacyChangesAfterCheckpoint = new ConcurrentLinkedQueue<>(); + private final java.util.IdentityHashMap legacyChangeVersions = new java.util.IdentityHashMap<>(); + private long legacySqliteRowMoveVersion; + private final HashMap legacyDirectPublicationSequences = new HashMap<>(); + private final java.util.Map legacyCheckpointLatestValues = new java.util.HashMap<>(); @Getter private UUID uuid; public UserDataCache(UserDataManager manager, UUID uuid) { this.uuid = uuid; this.manager = manager; + captureLegacyStorageType(); cachedChanges = new ConcurrentLinkedQueue<>(); cache = new HashMap<>(); } + private void captureLegacyStorageType() { + if (legacyStorageType == null && sharedFlushGate == null && manager != null && manager.getPlugin() != null) { + legacyStorageType = manager.getPlugin().getStorageType(); + com.bencodez.advancedcore.AdvancedCorePlugin.UserStorageOwner owner = manager.getPlugin().getNativeUserStorageOwner(); + if (owner != null && owner.storageType() == legacyStorageType) legacyStorageOwner = owner; + } + } + public synchronized boolean matchesLegacyOwner(com.bencodez.advancedcore.AdvancedCorePlugin.UserStorageOwner owner) { + return legacyStorageOwner == null || legacyStorageOwner.equals(owner); + } + public synchronized boolean matchesLegacyStorage(com.bencodez.advancedcore.api.user.UserStorage storage) { + return storage == null || (legacyStorageType != null && legacyStorageType == storage); + } private void initializeSharedStorage() { synchronized (this) { if (sharedFlushGate != null || uuid == null || cachedChanges == null) return; } @@ -67,11 +457,170 @@ public void addChange(UserDataChange change, boolean queue) { gate.accept(() -> addChangeInternal(change, queue)); return; } - legacyMutationOrder.readLock().lock(); + synchronized (this) { + if (legacyCheckpointsPending != 0 || !legacyMutationOrder.readLock().tryLock()) { + if (removing || uuid == null || cache == null || cachedChanges == null) + throw new IllegalStateException("User cache is retiring"); + if (change != null) { + publishChangeInternal(change); + if (legacyCheckpointsPending != 0) legacyCheckpointLatestValues.put(change.getKey(), change.toUserDataValue()); + if (queue) { legacyChangeVersions.put(change, snapshotVersion); legacyChangesAfterCheckpoint.add(change); } + } + return; + } + } try { addChangeInternal(change, queue); } finally { legacyMutationOrder.readLock().unlock(); } } + /** Keep a failed admitted direct write retryable without republishing its possibly stale value. */ + synchronized void retainFailedLegacyDirectChange(UserDataChange change, long admittedVersion, boolean sqlite) { + if (change == null || removing || uuid == null || cache == null || cachedChanges == null) + throw new IllegalStateException("Failed legacy direct write has no retryable cache"); + String key = change.getKey(); + boolean mysqlFold = !sqlite && key != null && legacyMysqlFoldsColumns(); + String mysqlAlias = mysqlFold ? resolveSharedMysqlKey(key) : null; + if (mysqlFold && mysqlAlias == null) throw new IllegalStateException("MySQL column identity is ambiguous during retry"); + long latest = key == null ? 0 : sqlite ? sqliteChangedAt.getOrDefault(sqliteDirectKey(key), 0L) : changedAt.getOrDefault(key, 0L); + if (mysqlFold) latest = Math.max(latest, changedAt.getOrDefault(mysqlAlias, 0L)); + if (latest <= admittedVersion) publishChangeInternal(change); + UserDataChange retry = change; + if ((sqlite || mysqlFold) && key != null && latest > admittedVersion) { + DataValue newer = sqlite ? sqliteChangedValues.get(sqliteDirectKey(key)) : cache.get(mysqlAlias); + if (newer == null) throw new IllegalStateException("Newer legacy value unavailable for retry"); + if (newer != null) { + cache.put(key, newer); + changedAt.put(key, latest); + if (sqlite) sqliteCacheKeys.put(sqliteDirectKey(key), key); + retry = new UserDataChange(key) { + @Override public void dump() { setKey(null); } + @Override public DataValue toUserDataValue() { return newer; } + }; + } + } + legacyChangeVersions.put(retry, snapshotVersion); cachedChanges.add(retry); + if (!scheduled) scheduleChanges(); + } + + private synchronized void beginLegacyCheckpoint() { legacyCheckpointsPending++; } + + /** Fence legacy row reads while a caller performs its existing direct write. */ + synchronized long beginLegacyDirectWrite() { + if (sharedFlushGate != null || removing || uuid == null || cache == null || cachedChanges == null) return -1; + legacyDirectWrites++; + replacementVersion = ++snapshotVersion; + return snapshotVersion; + } + + synchronized void endLegacyDirectWrite() { + if (legacyDirectWrites <= 0) throw new IllegalStateException("No legacy direct write is active"); + legacyDirectWrites--; + if (legacyDirectWrites == 0) legacyDirectPublicationSequences.clear(); + replacementVersion = ++snapshotVersion; + } + + /** Require a fresh authoritative read after an external bulk write. */ + synchronized void invalidateLegacyBulkSnapshot() { + if (cache == null) return; + storageSnapshotPublished = false; + completeStorageSnapshot = false; + replacementVersion = ++snapshotVersion; + } + synchronized void fenceIncomingSqliteMove(long admittedVersion) { + if (cache == null) return; + if (admittedVersion >= 0) legacySqliteRowMoveVersion = Math.max(legacySqliteRowMoveVersion, admittedVersion); + storageSnapshotPublished = false; completeStorageSnapshot = false; + replacementVersion = ++snapshotVersion; + } + + /** Reconcile a successful external bulk SET against changes admitted before it. */ + synchronized void completeLegacyBulkValues(java.util.Map values, long admittedVersion, + java.util.function.Function identity) { + if (cache == null || cachedChanges == null) return; + for (java.util.Map.Entry entry : values.entrySet()) { + String normalized = identity.apply(entry.getKey()); + boolean sqlite = normalized.startsWith("SQLITE:"); + boolean mysqlFold = normalized.startsWith("MYSQL:") && legacyMysqlFoldsColumns(); + String mysqlAlias = mysqlFold ? resolveSharedMysqlKey(entry.getKey()) : null; + if (mysqlFold && mysqlAlias == null) throw new IllegalStateException("MySQL column identity is ambiguous"); + long latest = sqlite ? sqliteChangedAt.getOrDefault(normalized, 0L) : changedAt.getOrDefault(entry.getKey(), 0L); + if (mysqlAlias != null) latest = Math.max(latest, changedAt.getOrDefault(mysqlAlias, 0L)); + boolean newer = latest > admittedVersion; + if (newer) continue; + DataValue value = entry.getValue() == null ? new com.bencodez.simpleapi.sql.data.DataValueString("") : entry.getValue(); + if (sqlite && normalized.equals(sqliteDirectKey("uuid")) && value.isString() && uuid != null && !uuid.toString().equals(value.getString())) { + legacySqliteRowMoveVersion = Math.max(legacySqliteRowMoveVersion, admittedVersion); + storageSnapshotPublished = false; + completeStorageSnapshot = false; + } + if (legacyCheckpointsPending != 0) legacyCheckpointLatestValues.put(entry.getKey(), value); + cache.put(entry.getKey(), value); + long version = ++snapshotVersion; + changedAt.put(entry.getKey(), version); + if (mysqlAlias != null) for (String alias : cache.keySet()) if (alias != null && !alias.equals(entry.getKey()) && alias.equalsIgnoreCase(entry.getKey())) { + cache.put(alias, value); changedAt.put(alias, version); + legacyExactBulkOverrides.put(alias, new LegacyBulkOverride(value, version, false)); + } + if (sqlite) { + for (String alias : cache.keySet()) if (alias != null && !alias.equals(entry.getKey()) && sqliteDirectKey(alias).equals(normalized)) { + cache.put(alias, value); changedAt.put(alias, version); + } + sqliteChangedAt.put(normalized, version); sqliteChangedValues.put(normalized, value); sqliteCacheKeys.put(normalized, entry.getKey()); + } + (sqlite ? legacySqliteBulkOverrides : legacyExactBulkOverrides).put(sqlite ? normalized : entry.getKey(), new LegacyBulkOverride(value, version, sqlite)); + } + } + + /** Reconcile older queued values lazily, without walking the queue on a direct writer. */ + private DataValue resolveLegacyBulkValue(String key, DataValue fallback) { + if (key == null) return fallback; + LegacyBulkOverride sqlite = legacySqliteBulkOverrides.get(sqliteDirectKey(key)); + LegacyBulkOverride exact = legacyExactBulkOverrides.get(key); + LegacyBulkOverride selected = sqlite == null ? exact : exact == null || sqlite.version() >= exact.version() ? sqlite : exact; + if (selected == null) return fallback; + long latest = selected.sqlite() ? sqliteChangedAt.getOrDefault(sqliteDirectKey(key), 0L) : changedAt.getOrDefault(key, 0L); + if (latest <= selected.version()) return selected.value(); + DataValue newer = selected.sqlite() ? sqliteChangedValues.get(sqliteDirectKey(key)) : cache.get(key); + return newer == null ? fallback : newer; + } + private DataValue resolveLegacyBulkValue(String key, DataValue fallback, Long queuedVersion) { + if (key == null || queuedVersion == null) return resolveLegacyBulkValue(key, fallback); + LegacyBulkOverride sqlite = legacySqliteBulkOverrides.get(sqliteDirectKey(key)); + LegacyBulkOverride exact = legacyExactBulkOverrides.get(key); + LegacyBulkOverride selected = sqlite == null ? exact : exact == null || sqlite.version() >= exact.version() ? sqlite : exact; + return selected != null && selected.version() > queuedVersion ? selected.value() : fallback; + } + private long effectiveLegacyQueuedVersion(String key, Long queuedVersion) { + long version = queuedVersion == null ? 0 : queuedVersion; + if (key == null) return version; + LegacyBulkOverride sqlite = legacySqliteBulkOverrides.get(sqliteDirectKey(key)); + LegacyBulkOverride exact = legacyExactBulkOverrides.get(key); + if (sqlite != null) version = Math.max(version, sqlite.version()); + if (exact != null) version = Math.max(version, exact.version()); + return version; + } + + private synchronized boolean finishLegacyCheckpoint() { + legacyCheckpointsPending--; + if (legacyCheckpointsPending == 0) { + if (!legacyChangesAfterCheckpoint.isEmpty() && (cache == null || cachedChanges == null)) + throw new IllegalStateException("Legacy cache retired with staged changes"); + UserDataChange change; + while ((change = legacyChangesAfterCheckpoint.poll()) != null) cachedChanges.add(change); + legacyCheckpointLatestValues.clear(); + } + return cachedChanges != null && !cachedChanges.isEmpty() && !scheduled; + } + + private void finishLegacyCheckpointAndUnlock() { + boolean schedule = false; + synchronized (this) { + try { schedule = finishLegacyCheckpoint(); } + finally { legacyMutationOrder.writeLock().unlock(); } + } + if (schedule) scheduleChanges(); + } + /** * Publish into an already bound cache without waiting for its user gate. The * cache monitor serializes this with retirement markers: a change accepted @@ -140,6 +689,14 @@ private boolean tryAddChangesBeforeDeferredSharedFlush(Iterable /** Supported arithmetic for a bounded, conditional cached integer mutation. */ public enum IntegerMutation { SET, ADD, ADD_FLOOR_ZERO, DEBIT_POSITIVE } + /** Cache mutation may have started; callers must not retry it as a new operation. */ + public static final class LegacyIntegerMutationUncertainException extends IllegalStateException { + public LegacyIntegerMutationUncertainException(Throwable cause) { super("Legacy integer mutation outcome is uncertain", cause); } + } + /** Publication began but its full outcome is unknown; retrying may duplicate an award. */ + public static final class SharedIntegerMutationUncertainException extends IllegalStateException { + public SharedIntegerMutationUncertainException(Throwable cause) { super("Shared integer mutation outcome is uncertain", cause); } + } /** Previous values are returned so a caller can compensate precisely. */ public record IntegerMutationResult(boolean applied, java.util.Map previous) { } @@ -149,18 +706,27 @@ public record IntegerMutationResult(boolean applied, java.util.Map operands, IntegerMutation operation, java.util.function.Consumer> notification) { + UUID identity = uuid; + boolean admission = manager != null && identity != null && manager.hasSharedRuntimeLifecycle(); + if (admission && !manager.beginSharedSynchronousIntegerMutation(identity)) return null; + try { return tryMutateIntegersAdmitted(operands, operation, notification); } + finally { if (admission) manager.endSharedSynchronousIntegerMutation(identity); } + } + + private IntegerMutationResult tryMutateIntegersAdmitted(java.util.Map operands, + IntegerMutation operation, java.util.function.Consumer> notification) { Objects.requireNonNull(operands, "operands"); Objects.requireNonNull(operation, "operation"); if (operands.isEmpty() || operands.size() > 64) throw new IllegalArgumentException("Invalid integer mutation size"); - var ordered = new java.util.LinkedHashMap(); + java.util.LinkedHashMap ordered = new java.util.LinkedHashMap(); int copied = 0; - for (var entry : operands.entrySet()) { + for (java.util.Map.Entry entry : operands.entrySet()) { if (++copied > 64) throw new IllegalArgumentException("Invalid integer mutation size"); String key = entry.getKey(); Integer value = entry.getValue(); if (key == null || key.isEmpty() || value == null) throw new IllegalArgumentException("Invalid integer mutation"); @@ -173,16 +739,27 @@ public IntegerMutationResult tryMutateIntegers(java.util.Map op IntegerMutationResult result; synchronized (this) { if (sharedFlushGate == null || removing || uuid == null || cache == null || cachedChanges == null - || !storageSnapshotPublished) return null; + || !storageSnapshotPublished || exclusiveFlushesPending != 0) return null; + boolean sqlite = sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE; + java.util.LinkedHashMap actualKeys = new java.util.LinkedHashMap(); + java.util.HashSet identities = new java.util.HashSet(); + for (String key : ordered.keySet()) { + if (sqlite && !identities.add(sqliteDirectKey(key))) throw new IllegalArgumentException("Duplicate SQLite column identity"); + if (sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && hasAmbiguousMysqlKey(key)) return null; + String actual = sqlite ? resolveSharedSqliteKey(key) : key; + if (actual == null) return null; + actualKeys.put(key, actual); + } var previous = new java.util.LinkedHashMap(); for (String key : ordered.keySet()) { - if (!cache.containsKey(key) || cache.get(key) == null) return null; - DataValue value = cache.get(key); + String actual = actualKeys.get(key); + if (!cache.containsKey(actual) || cache.get(actual) == null) return null; + DataValue value = cache.get(actual); // Direct snapshot replacement intentionally remains visible to legacy // readers. It must not let arithmetic forget an accepted write whose // batch is still in flight. A newer local mutation retains changedAt. - if (!changedAt.containsKey(key) && inFlightValues.containsKey(key)) - value = inFlightValues.get(key); + if (!changedAt.containsKey(actual) && inFlightValues.containsKey(actual)) + value = inFlightValues.get(actual); int number; if (value.isInt()) number = value.getInt(); else if (value.isString()) { @@ -201,39 +778,153 @@ else if (value.isString()) { case ADD, DEBIT_POSITIVE -> Math.addExact(old, operand); case ADD_FLOOR_ZERO -> Math.max(0, Math.addExact(old, operand)); }; - changes.add(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt(key, updated)); + changes.add(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt(actualKeys.get(key), updated)); changed.add(key); }); result = new IntegerMutationResult(!changes.isEmpty(), java.util.Map.copyOf(previous)); if (changes.isEmpty()) return result; - if (exclusiveFlushesPending != 0) { - for (UserDataChange change : changes) { publishChangeInternal(change); changesAfterExclusiveFlush.add(change); } - if (notification != null) { + try { + for (UserDataChange change : changes) { + publishChangeInternal(change); + cachedChanges.add(change); + } + } catch (RuntimeException | Error failure) { throw new SharedIntegerMutationUncertainException(failure); } + if (!scheduled) { + try { scheduleChanges(); } + catch (RuntimeException | Error failure) { + try { if (manager != null) manager.recordSharedStorageFailure(failure); } + catch (RuntimeException | Error ignored) { } + } + } + if (notification != null) { + try { var snapshot = java.util.Set.copyOf(changed); Runnable raw = () -> notification.accept(snapshot); Runnable captured = manager.captureSharedUserDataNotification(raw); - notificationsAfterExclusiveFlush.add(captured == null ? raw : captured); + notify = captured == null ? raw : captured; + } catch (RuntimeException | Error failure) { + try { if (manager != null) manager.recordSharedStorageFailure(failure); } + catch (RuntimeException | Error ignored) { } } - return result; - } - for (UserDataChange change : changes) addChangeInternal(change, true); - if (notification != null) { - var snapshot = java.util.Set.copyOf(changed); - Runnable raw = () -> notification.accept(snapshot); - Runnable captured = manager.captureSharedUserDataNotification(raw); - notify = captured == null ? raw : captured; } } if (notify != null) { try { manager.dispatchSharedUserDataNotification(notify); } - catch (RejectedExecutionException rejected) { - // The manager records submission failures. Returning the accepted - // result prevents callers from retrying an already-applied debit. + catch (RuntimeException | Error failure) { + // The mutation is already accepted; a failed notification cannot + // turn it into a retryable failure. + try { manager.recordSharedStorageFailure(failure); } + catch (RuntimeException | Error ignored) { } } } return result; } + /** Nonblocking cached arithmetic for a legacy cache; null means retry on a worker. */ + public IntegerMutationResult tryMutateLegacyIntegers(java.util.Map operands, + IntegerMutation operation, java.util.function.Consumer> notification) { + Objects.requireNonNull(operands, "operands"); Objects.requireNonNull(operation, "operation"); + if (operands.isEmpty() || operands.size() > 64) throw new IllegalArgumentException("Invalid integer mutation size"); + java.util.LinkedHashMap ordered = new java.util.LinkedHashMap(); + int copied = 0; + for (java.util.Map.Entry entry : operands.entrySet()) { + if (++copied > 64) throw new IllegalArgumentException("Invalid integer mutation size"); + String key = entry.getKey(); Integer value = entry.getValue(); + if (key == null || key.isEmpty() || value == null || (operation == IntegerMutation.DEBIT_POSITIVE && value != -1)) + throw new IllegalArgumentException("Invalid integer mutation"); + if (ordered.size() == 64) throw new IllegalArgumentException("Invalid integer mutation size"); + ordered.put(key, value); + } + if (ordered.isEmpty()) throw new IllegalArgumentException("Invalid integer mutation size"); + boolean sqlite = manager != null && manager.getPlugin().getStorageType() == com.bencodez.advancedcore.api.user.UserStorage.SQLITE; + boolean mysql = manager != null && manager.getPlugin().getStorageType() == com.bencodez.advancedcore.api.user.UserStorage.MYSQL; + Boolean mysqlFold = mysql && manager.getPlugin().getMysql() != null ? manager.getPlugin().getMysql().caseInsensitiveColumnNames() : null; + if (sqlite) { + java.util.HashSet identities = new java.util.HashSet(); + for (String key : ordered.keySet()) if (!identities.add(sqliteDirectKey(key))) throw new IllegalArgumentException("Duplicate SQLite column identity"); + } + if (mysql && !Boolean.FALSE.equals(mysqlFold)) { + java.util.HashSet identities = new java.util.HashSet(); + for (String key : ordered.keySet()) if (!identities.add(key.toLowerCase(java.util.Locale.ROOT))) return null; + } + if (!legacyMutationOrder.readLock().tryLock()) return null; + IntegerMutationResult result; + java.util.LinkedHashSet changed = new java.util.LinkedHashSet(); + try { + synchronized (this) { + if (sharedFlushGate != null || removing || legacyDirectWrites != 0 || legacyCheckpointsPending != 0 || uuid == null || cache == null || cachedChanges == null + || !storageSnapshotPublished) return null; + if (manager != null && manager.getPlugin() != null && (!matchesLegacyStorage(manager.getPlugin().getStorageType()) + || !matchesLegacyOwner(manager.getPlugin().getNativeUserStorageOwner()))) return null; + java.util.LinkedHashMap previous = new java.util.LinkedHashMap(); + for (String key : ordered.keySet()) { + String actual = key; + if (sqlite) { + String identity = sqliteDirectKey(key); + if (sqliteCacheKeys.containsKey(identity)) { + actual = sqliteCacheKeys.get(identity); + if (actual == null) return null; + } + } + if (mysql && Boolean.TRUE.equals(mysqlFold)) { + actual = resolveSharedMysqlKey(key); + if (actual == null) return null; + } else if (mysql && mysqlFold == null && hasAmbiguousMysqlKey(key)) return null; + DataValue value = cache.get(actual); + if (!changedAt.containsKey(actual) && inFlightValues.containsKey(actual)) value = inFlightValues.get(actual); + if (!completeStorageSnapshot && !changedAt.containsKey(actual) && !inFlightValues.containsKey(actual)) return null; + if (value == null) { + if (!completeStorageSnapshot || cache.containsKey(actual)) return null; + previous.put(key, 0); continue; + } + int number; + if (value.isInt()) number = value.getInt(); + else if (value.isString()) { try { number = Integer.parseInt(value.getString()); } catch (NumberFormatException invalid) { number = 0; } } + else number = 0; + previous.put(key, number); + } + java.util.ArrayList changes = new java.util.ArrayList(); + for (java.util.Map.Entry entry : ordered.entrySet()) { + int old = previous.get(entry.getKey()); + if (operation == IntegerMutation.DEBIT_POSITIVE && old <= 0) continue; + int updated = switch (operation) { + case SET -> entry.getValue(); + case ADD, DEBIT_POSITIVE -> Math.addExact(old, entry.getValue()); + case ADD_FLOOR_ZERO -> Math.max(0, Math.addExact(old, entry.getValue())); + }; + String actual = entry.getKey(); + if (sqlite) actual = sqliteCacheKeys.getOrDefault(sqliteDirectKey(actual), actual); + if (mysql && Boolean.TRUE.equals(mysqlFold)) actual = resolveSharedMysqlKey(actual); + changes.add(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt(actual, updated)); + changed.add(entry.getKey()); + } + try { + for (UserDataChange change : changes) { + publishChangeInternal(change); + legacyChangeVersions.put(change, snapshotVersion); + cachedChanges.add(change); + if (sqlite) { + String identity = sqliteDirectKey(change.getKey()); + for (String alias : cache.keySet()) if (alias != null && !alias.equals(change.getKey()) && sqliteDirectKey(alias).equals(identity)) { + cache.put(alias, change.toUserDataValue()); changedAt.put(alias, snapshotVersion); + } + } + } + } catch (RuntimeException | Error failure) { throw new LegacyIntegerMutationUncertainException(failure); } + if (!changes.isEmpty() && !scheduled) { + try { scheduleChanges(); } + catch (RuntimeException | Error failure) { + try { if (manager != null) manager.recordSharedStorageFailure(failure); } + catch (RuntimeException | Error ignored) { } + } + } + result = new IntegerMutationResult(!changed.isEmpty(), java.util.Map.copyOf(previous)); + } + } finally { legacyMutationOrder.readLock().unlock(); } + if (!changed.isEmpty() && notification != null) notification.accept(java.util.Set.copyOf(changed)); + return result; + } + 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"); @@ -246,6 +937,7 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue if (change == null || cache == null || cachedChanges == null) return; publishChangeInternal(change); if (queue) { + if (sharedFlushGate == null) legacyChangeVersions.put(change, snapshotVersion); cachedChanges.add(change); if (!scheduled) scheduleChanges(); } @@ -253,8 +945,47 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue private void publishChangeInternal(UserDataChange change) { if (change == null || cache == null) return; - cache.put(change.getKey(), change.toUserDataValue()); - changedAt.put(change.getKey(), ++snapshotVersion); + captureLegacyStorageType(); + LegacyPublication publication = LEGACY_DIRECT_PUBLICATION.get(); + if (publication != null && publication.cache() == this && change.getKey() != null) { + String identity = legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE + ? sqliteDirectKey(change.getKey()) : legacyMysqlFoldsColumns() + ? change.getKey().toLowerCase(java.util.Locale.ROOT) : change.getKey(); + long sequence = publication.manager().legacyDirectSequence(publication.admission()); + if (legacyDirectPublicationSequences.getOrDefault(identity, Long.MIN_VALUE) > sequence) return; + legacyDirectPublicationSequences.put(identity, sequence); + } + String mysqlAlias = null; + if (change.getKey() != null && sharedFlushGate == null && legacyMysqlFoldsColumns()) { + mysqlAlias = resolveSharedMysqlKey(change.getKey()); + if (mysqlAlias == null) throw new IllegalStateException("MySQL column identity is ambiguous"); + } + if (publication != null && publication.cache() == this && change.getKey() != null) + publication.manager().rememberLegacyDirectCachePublication(publication.admission(), this, captureDirectCachePublication(change.getKey(), mysqlAlias)); + DataValue value = change.toUserDataValue(); + cache.put(change.getKey(), value); + long version = ++snapshotVersion; + changedAt.put(change.getKey(), version); + if (publication != null && publication.cache() == this) publication.manager().recordLegacyDirectCachePublication(publication.admission(), this, version); + if (mysqlAlias != null) for (String alias : cache.keySet()) if (alias != null && !alias.equals(change.getKey()) && alias.equalsIgnoreCase(change.getKey())) { + cache.put(alias, value); changedAt.put(alias, version); + } + if (change.getKey() != null) { + String sqliteKey = sqliteDirectKey(change.getKey()); + if (sharedFlushGate == null && legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) { + for (String alias : cache.keySet()) if (alias != null && !alias.equals(change.getKey()) && sqliteDirectKey(alias).equals(sqliteKey)) { + cache.put(alias, value); changedAt.put(alias, version); + } + } + sqliteChangedAt.put(sqliteKey, version); + sqliteChangedValues.put(sqliteKey, value); + sqliteCacheKeys.put(sqliteKey, change.getKey()); + } + } + private boolean legacyMysqlFoldsColumns() { + return legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL + && manager != null && manager.getPlugin() != null && manager.getPlugin().getMysql() != null + && Boolean.TRUE.equals(manager.getPlugin().getMysql().caseInsensitiveColumnNames()); } public UserDataCache cache() { @@ -267,7 +998,13 @@ UserDataCache cacheInternal(boolean notify) { return this; } - ArrayList refreshInternal(boolean notify) { + ArrayList refreshInternal(boolean notify) { return refreshInternal(notify, false); } + + ArrayList refreshInternal(boolean notify, boolean completeSharedRow) { + return refreshInternal(notify, completeSharedRow, null); + } + + ArrayList refreshInternal(boolean notify, boolean completeSharedRow, UserDataManager.LegacyRead legacyRead) { initializeSharedStorage(); UUID currentUuid; long expectedVersion; @@ -288,7 +1025,11 @@ ArrayList refreshInternal(boolean notify) { refreshNotification = captured == null ? rawNotification : captured; } ArrayList keys = user.getUserData().getKeys(); - HashMap data = user.getUserData().getValues(); + com.bencodez.advancedcore.api.user.UserData userData = user.getUserData(); + boolean canonicalRowRead = completeSharedRow && usesCanonicalValuesReader(userData); + HashMap data = canonicalRowRead ? userData.getValuesIfComplete() : userData.getValues(); + boolean completeRowRead = canonicalRowRead && data != null && manager.hasAuthoritativeSharedRowReads(); + if (data == null) data = new HashMap<>(); 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 @@ -303,14 +1044,17 @@ ArrayList refreshInternal(boolean notify) { else manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + currentUuid); refreshed.put(key, dataValue); } - HashMap published; - synchronized (this) { + final boolean complete = completeRowRead; + java.util.function.Supplier> publication = () -> { + 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); - } + if (uuid == null || cache == null || removing) return null; + return updateSharedSnapshot(refreshed, expectedVersion, currentUuid, refreshedStoredDataPresent, complete); + } + }; + HashMap published = legacyRead == null ? publication.get() : manager.publishLegacyRead(legacyRead, publication); + if (published == null) return 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()); @@ -320,6 +1064,20 @@ ArrayList refreshInternal(boolean notify) { return changedKeys; } + /** Preserve custom getValues overrides without claiming that their maps are complete rows. */ + private static boolean usesCanonicalValuesReader(com.bencodez.advancedcore.api.user.UserData data) { + if (data == null) return false; + try { return data.getClass().getMethod("getValues").getDeclaringClass() + == com.bencodez.advancedcore.api.user.UserData.class + && data.getClass().getMethod("getValues", com.bencodez.advancedcore.api.user.UserStorage.class) + .getDeclaringClass() == com.bencodez.advancedcore.api.user.UserData.class + && data.getClass().getMethod("convert", java.util.List.class).getDeclaringClass() + == com.bencodez.advancedcore.api.user.UserData.class + && data.getClass().getMethod("getSQLiteRow").getDeclaringClass() == com.bencodez.advancedcore.api.user.UserData.class + && data.getClass().getMethod("getMySqlRow").getDeclaringClass() == com.bencodez.advancedcore.api.user.UserData.class; } + catch (ReflectiveOperationException | SecurityException unavailable) { return false; } + } + public void clearCache() { if (manager != null && manager.deferSharedStorageWork(this::clearCacheNow)) return; clearCacheNow(); @@ -386,8 +1144,17 @@ public void flushChangesAndRun(Runnable action) { } return; } + beginLegacyCheckpoint(); legacyMutationOrder.writeLock().lock(); try { + { + synchronized (this) { + if (!legacyChangesAfterCheckpoint.isEmpty() && (cache == null || cachedChanges == null)) + throw new IllegalStateException("Legacy cache retired with staged changes"); + UserDataChange staged; + while ((staged = legacyChangesAfterCheckpoint.poll()) != null) cachedChanges.add(staged); + } + } while (true) { processChanges(); synchronized (this) { @@ -404,7 +1171,7 @@ public void flushChangesAndRun(Runnable action) { action.run(); return; } - } finally { legacyMutationOrder.writeLock().unlock(); } + } finally { finishLegacyCheckpointAndUnlock(); } } /** Caller holds either this monitor or the exclusive per-user admission. */ @@ -432,18 +1199,38 @@ private void drainChangesStagedBeforeExclusiveAdmission(ArrayList noti private void clearCacheNow() { initializeSharedStorage(); Consumer gate; - synchronized (this) { - gate = sharedFlushGate; - if (gate == null) { - if (hasChangesToProcess()) processChanges(); - if (cache != null) { cache.clear(); recordSnapshotReplacement(); } - return; - } + synchronized (this) { gate = sharedFlushGate; } + if (gate == null) { + if (legacyMutationOrder.isWriteLockedByCurrentThread()) + throw new IllegalStateException("Cannot clear a legacy cache inside its active checkpoint"); + beginLegacyCheckpoint(); + legacyMutationOrder.writeLock().lock(); + try { + synchronized (this) { + gate = sharedFlushGate; + if (gate == null) { + if (inFlightBatches != 0) throw new IllegalStateException("Legacy cache has an in-flight batch"); + if (!legacyChangesAfterCheckpoint.isEmpty() && (cache == null || cachedChanges == null)) + throw new IllegalStateException("Legacy cache retired with staged changes"); + UserDataChange staged; + while ((staged = legacyChangesAfterCheckpoint.poll()) != null) cachedChanges.add(staged); + } + } + if (gate == null) { + if (hasChangesToProcess()) processChanges(); + synchronized (this) { + if (inFlightBatches != 0 || (cachedChanges != null && !cachedChanges.isEmpty())) + throw new IllegalStateException("Legacy cache still has unflushed work"); + if (cache != null) { cache.clear(); storageSnapshotPublished = false; completeStorageSnapshot = false; recordSnapshotReplacement(); } + } + return; + } + } finally { finishLegacyCheckpointAndUnlock(); } } 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(); } } + synchronized (this) { if (cache != null) { cache.clear(); storageSnapshotPublished = false; completeStorageSnapshot = false; recordSnapshotReplacement(); } } }); Runnable callback = notification.get(); deliverNotification(callback); @@ -506,8 +1293,10 @@ private void dumpNow() { while (inFlightBatches > 0) { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } + if (sharedFlushGate == null && (legacyCheckpointsPending != 0 || !legacyChangesAfterCheckpoint.isEmpty())) + throw new IllegalStateException("Cannot retire a legacy cache with an active checkpoint"); if (cachedChanges != null && !cachedChanges.isEmpty()) continue; - recordSnapshotReplacement(); cache = null; cachedChanges = null; uuid = null; scheduled = false; return; + recordSnapshotReplacement(); legacyChangeVersions.clear(); cache = null; cachedChanges = null; uuid = null; scheduled = false; return; } } } @@ -516,6 +1305,7 @@ private void dumpNow() { public synchronized boolean hasCache() { return cache != null && !cache.isEmpty(); } public synchronized boolean hasStoredData() { return storedDataPresent; } public synchronized boolean hasPublishedStorageSnapshot() { return storageSnapshotPublished; } + public synchronized boolean hasEverPublishedStorageSnapshot() { return hasEverPublishedStorageSnapshot; } /** * Retire only externally changed values without flushing or deleting unrelated * pending writes. Readers must not mistake these missing values for defaults. @@ -532,9 +1322,77 @@ public synchronized void invalidateStorageSnapshot(String... keys) { } if (!invalidated) return; storageSnapshotPublished = false; + completeStorageSnapshot = false; recordSnapshotReplacement(); } + /** Seed an absent integer only after a complete, successful storage-row read. */ + public synchronized boolean seedMissingIntegerFromCompleteSnapshot(String key) { + Objects.requireNonNull(key, "key"); + if (key.isEmpty() || !completeStorageSnapshot || !storageSnapshotPublished || removing + || uuid == null || cache == null || cachedChanges == null || sharedFlushGate == null) return false; + boolean sqlite = sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE; + if (sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && hasAmbiguousMysqlKey(key)) return false; + String actual = sqlite ? resolveSharedSqliteKey(key) : key; + if (actual == null) return false; + if (!cache.containsKey(actual)) { + if (sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL) { + // An arbitrary SQL provider may not share the native schema's folding + // rules. Refuse to seed a zero when another spelling could name the + // same stored column; do not guess or merge custom-provider keys. + if (!inFlightValues.isEmpty() || !cachedChanges.isEmpty()) return false; + } + if (sqlite && (!inFlightValues.isEmpty() || !cachedChanges.isEmpty())) return false; + DataValue inFlight = inFlightValues.get(actual); + cache.put(actual, inFlight != null ? inFlight : new com.bencodez.simpleapi.sql.data.DataValueInt(0)); + if (sqlite) sqliteCacheKeys.put(sqliteDirectKey(actual), actual); + } + return cache.get(actual) != null; + } + + private boolean hasAmbiguousMysqlKey(String key) { + if (cache.size() > 2048 || inFlightValues.size() > 2048) return true; + for (String candidate : cache.keySet()) if (candidate != null && !candidate.equals(key) && candidate.equalsIgnoreCase(key)) return true; + for (String candidate : inFlightValues.keySet()) if (candidate != null && !candidate.equals(key) && candidate.equalsIgnoreCase(key)) return true; + return false; + } + private String resolveSharedMysqlKey(String key) { + if (cache.size() > 2048 || inFlightValues.size() > 2048) return null; + String found = null; + for (String candidate : cache.keySet()) if (candidate != null && candidate.equalsIgnoreCase(key)) { + if (found != null && !found.equals(candidate) && !samePublishedMysqlAlias(found, candidate)) return null; + found = candidate; + } + for (String candidate : inFlightValues.keySet()) if (candidate != null && candidate.equalsIgnoreCase(key)) { + if (found != null && !found.equals(candidate) && !samePublishedMysqlAlias(found, candidate)) return null; + found = candidate; + } + return found == null ? key : found; + } + private boolean samePublishedMysqlAlias(String first, String second) { + if (!cache.containsKey(first) || !cache.containsKey(second) || cache.get(first) != cache.get(second)) return false; + Long firstVersion = changedAt.get(first), secondVersion = changedAt.get(second); + if (firstVersion != null && firstVersion.equals(secondVersion)) return true; + if (firstVersion == null && secondVersion != null) return persistedAt.getOrDefault(first, 0L) > secondVersion; + if (secondVersion == null && firstVersion != null) return persistedAt.getOrDefault(second, 0L) > firstVersion; + return false; + } + + /** Resolve SQLite's ASCII identifier folding without guessing among aliases. */ + private String resolveSharedSqliteKey(String key) { + if (cache.size() > 2048 || inFlightValues.size() > 2048) return null; + String identity = sqliteDirectKey(key), found = null; + for (String candidate : cache.keySet()) if (candidate != null && sqliteDirectKey(candidate).equals(identity)) { + if (found != null && !found.equals(candidate)) return null; + found = candidate; + } + for (String candidate : inFlightValues.keySet()) if (candidate != null && sqliteDirectKey(candidate).equals(identity)) { + if (found != null && !found.equals(candidate)) return null; + found = candidate; + } + return found == null ? key : found; + } + /** Observe publication and values under the same cache monitor. */ public synchronized HashMap snapshotIfPublished() { return storageSnapshotPublished ? (cache == null ? new HashMap<>() : new HashMap<>(cache)) : null; @@ -563,10 +1421,16 @@ public void configureSharedStorage(Consumer> writer, public synchronized void configureSharedStorage(Consumer> writer, Consumer gate, Consumer exclusiveGate) { + configureSharedStorage(writer, gate, exclusiveGate, null); + } + public synchronized void configureSharedStorage(Consumer> writer, Consumer gate, + Consumer exclusiveGate, com.bencodez.advancedcore.api.user.UserStorage storageType) { if (sharedFlushGate != null && sharedFlushGate != gate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); if (sharedExclusiveFlushGate != null && sharedExclusiveFlushGate != exclusiveGate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); + if (sharedStorageType != null && storageType != null && sharedStorageType != storageType) throw new IllegalStateException("Shared user cache belongs to another storage generation"); if (sharedFlushGate == null && gate != null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); setSharedStorageWriter(writer); + if (storageType != null) sharedStorageType = storageType; sharedFlushGate = gate; sharedExclusiveFlushGate = exclusiveGate; } @@ -577,8 +1441,8 @@ public synchronized void setSharedStorageWriter(Consumer> writer = null; Consumer gate; ArrayList changes = new ArrayList<>(); HashMap persistedMutationVersions = new HashMap<>(); + HashMap physicalCandidateVersions = new HashMap<>(); + java.util.Set legacyBatchChanges = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); boolean legacyAdmission = false; synchronized (this) { 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); } - } + if (sharedBatchThread == Thread.currentThread()) throw new IllegalStateException("Cannot reenter an active cache flush"); + 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 && legacySqliteRowMoveVersion != 0 && cachedChanges.stream().anyMatch(pending -> { + Long version = legacyChangeVersions.get(pending); + return version == null || version <= legacySqliteRowMoveVersion; + })) throw new com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.StaleRowWriteException("Queued SQLite mutation targets a moved row"); + if (writer != null && !legacyChangeVersions.isEmpty() + && (!legacyExactBulkOverrides.isEmpty() || !legacySqliteBulkOverrides.isEmpty()) + && (legacyStorageType == null || sharedStorageType == null || legacyStorageType != sharedStorageType)) + throw new IllegalStateException("Cannot route pending legacy writes to a different or unknown shared storage"); + if (writer == null && manager != null && manager.getPlugin() != null + && (!matchesLegacyStorage(manager.getPlugin().getStorageType()) + || !matchesLegacyOwner(manager.getPlugin().getNativeUserStorageOwner()))) + throw new IllegalStateException("Cannot flush legacy cache to a different configured storage"); if (writer == null && manager != null) { manager.beginLegacyCacheBatch(); legacyAdmission = true; @@ -654,12 +1541,36 @@ private Runnable processChangesInternal(boolean admitted) { 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); + Long version = legacyChangeVersions.get(queuedChange); + if (version != null) legacyBatchChanges.add(queuedChange); + if (version == null) version = changedAt.get(queuedChange.getKey()); + if (version != null) persistedMutationVersions.merge(queuedChange.getKey(), version, Math::max); } inFlightValues.clear(); try { - for (UserDataChange changeEntry : changes) inFlightValues.put(changeEntry.getKey(), changeEntry.toUserDataValue()); + for (UserDataChange changeEntry : changes) { + String key = changeEntry.getKey(); + DataValue value = changeEntry.toUserDataValue(); + boolean legacyChange = writer == null || legacyBatchChanges.contains(changeEntry); + boolean stagedKey = key != null && legacyChangesAfterCheckpoint.stream().anyMatch(staged -> staged.getKey() != null + && (key.equals(staged.getKey()) || (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE && sqliteDirectKey(key).equals(sqliteDirectKey(staged.getKey()))))); + // A later direct legacy SET may already have superseded this queued + // value. Keep staged post-checkpoint changes behind their checkpoint. + if (legacyChange && changedAt.containsKey(key) && cache != null && cache.get(key) != null + && !stagedKey) + value = cache.get(key); + if (legacyChange && !stagedKey && key != null && legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) { + String identity = sqliteDirectKey(key); + if (sqliteChangedAt.getOrDefault(identity, 0L) > changedAt.getOrDefault(key, 0L) && sqliteChangedValues.containsKey(identity)) + value = sqliteChangedValues.get(identity); + } + if (legacyChange) { + Long queuedVersion = legacyChangeVersions.get(changeEntry); + value = resolveLegacyBulkValue(key, value, queuedVersion); + physicalCandidateVersions.merge(key, effectiveLegacyQueuedVersion(key, queuedVersion), Math::max); + } + inFlightValues.put(key, value); + } } catch (RuntimeException | Error preparationFailure) { inFlightValues.clear(); requeueChanges(changes); @@ -667,7 +1578,7 @@ private Runnable processChangesInternal(boolean admitted) { throw preparationFailure; } inFlightBatches++; - if (writer != null) sharedBatchThread = Thread.currentThread(); + sharedBatchThread = Thread.currentThread(); } } if (gate != null) { @@ -683,13 +1594,59 @@ private Runnable processChangesInternal(boolean admitted) { 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()) { if (writer == null) user.getUserData().setValues(values); else writer.accept(values); } + HashMap physicalKeys = new HashMap<>(); + HashMap physicalVersions = new HashMap<>(); + for (UserDataChange change : changes) { + String key = change.getKey(); + boolean legacyChange = writer == null || legacyBatchChanges.contains(change); + String physical = null; + if (legacyChange && key != null) { + if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) physical = sqliteDirectKey(key); + else if (legacyMysqlFoldsColumns()) physical = key.toLowerCase(java.util.Locale.ROOT); + } + if (physical == null) values.put(key, legacyChange ? inFlightValues.get(key) : change.toUserDataValue()); + else { + long version = physicalCandidateVersions.getOrDefault(key, 0L); + if (version >= physicalVersions.getOrDefault(physical, Long.MIN_VALUE)) { + String previous = physicalKeys.put(physical, key); + if (previous != null && !previous.equals(key)) values.remove(previous); + values.put(key, inFlightValues.get(key)); physicalVersions.put(physical, version); + } + } + keys.add(change.getKey()); + } + if (!values.isEmpty()) { + if (writer == null) { + if (!matchesLegacyStorage(manager.getPlugin().getStorageType()) + || !matchesLegacyOwner(manager.getPlugin().getNativeUserStorageOwner())) + throw new IllegalStateException("Cannot flush legacy cache to a different configured storage"); + LegacyBatchContext previous = LEGACY_BATCH_PERSISTENCE.get(); + if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) { + com.bencodez.advancedcore.api.user.userstorage.sql.UserTable table = manager.getPlugin().getSQLiteUserTable(); + if (table != null) manager.beginConfirmedBatchHistory(legacyRead, table.legacyDirectProviderIdentity(), 0, table); + } else if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL) { + com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL table = manager.getPlugin().getMysql(); + if (table != null) { + Boolean fold = table.caseInsensitiveColumnNames(); + if (fold != null) manager.beginConfirmedBatchHistory(legacyRead, table, fold ? 2 : 1, table); + } + } + LegacyBatchContext context = new LegacyBatchContext(manager, currentUuid, values, legacyRead, legacyStorageType, new java.util.concurrent.atomic.AtomicReference<>()); + LEGACY_BATCH_PERSISTENCE.set(context); + try { + user.getUserData().setValues(values); + if (context.delegatedFailure().get() != null) throw new IllegalStateException("Delegated legacy batch storage failed", context.delegatedFailure().get()); + } + finally { if (previous == null) LEGACY_BATCH_PERSISTENCE.remove(); else LEGACY_BATCH_PERSISTENCE.set(previous); } + } else writer.accept(values); + } persisted = true; synchronized (this) { + for (UserDataChange change : changes) if (writer == null || legacyBatchChanges.contains(change)) legacyChangeVersions.remove(change); if (!values.isEmpty()) storedDataPresent = true; if (cache != null) { for (UserDataChange persistedChange : changes) { + if (writer == null && manager.wasConfirmedNestedKeyFiltered(legacyRead, persistedChange.getKey(), legacyStorageType)) continue; 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 @@ -700,7 +1657,14 @@ private Runnable processChangesInternal(boolean admitted) { // value visible which will never be persisted. if ((storageSnapshotPublished || cache.containsKey(persistedChange.getKey())) && (queuedVersion == null || Objects.equals(persistedVersion, queuedVersion))) { - cache.put(persistedChange.getKey(), persistedChange.toUserDataValue()); + String key = persistedChange.getKey(); + String physical = null; + if ((writer == null || legacyBatchChanges.contains(persistedChange)) && key != null) { + if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) physical = sqliteDirectKey(key); + else if (legacyMysqlFoldsColumns()) physical = key.toLowerCase(java.util.Locale.ROOT); + } + String winningKey = physical == null ? key : physicalKeys.getOrDefault(physical, key); + cache.put(key, values.get(winningKey)); } } } @@ -710,7 +1674,11 @@ private Runnable processChangesInternal(boolean admitted) { // key's original mutation predates a later mutation of another key. if (!values.isEmpty()) { long completedVersion = ++snapshotVersion; - values.keySet().forEach(key -> persistedAt.put(key, completedVersion)); + for (java.util.Map.Entry entry : values.entrySet()) { + if (writer == null && manager.wasConfirmedNestedKeyFiltered(legacyRead, entry.getKey(), legacyStorageType)) continue; + persistedAt.put(entry.getKey(), completedVersion); + persistedValues.put(entry.getKey(), entry.getValue()); + } } persistedMutationVersions.forEach((key, version) -> { if (version.equals(changedAt.get(key))) changedAt.remove(key); @@ -720,7 +1688,9 @@ private Runnable processChangesInternal(boolean admitted) { changedKeys = ArrayUtils.convert(keys); } catch (RuntimeException | Error e) { if (!persisted) { - try { requeueChanges(changes); } + try { + requeueChanges(changes, writer == null ? persistedMutationVersions : null); + } catch (RuntimeException | Error recoveryFailure) { if (recoveryFailure != e) e.addSuppressed(recoveryFailure); } @@ -747,7 +1717,16 @@ private Runnable processChangesInternal(boolean admitted) { } private synchronized void requeueChanges(ArrayList changes) { + requeueChanges(changes, null); + } + + private synchronized void requeueChanges(ArrayList changes, HashMap legacyVersions) { if (changes == null || changes.isEmpty() || cachedChanges == null) return; + HashMap newerLegacyValues = new HashMap<>(); + if (legacyVersions != null && cache != null) legacyVersions.forEach((key, version) -> { + Long latest = changedAt.get(key); + if (latest != null && latest > version && cache.containsKey(key)) newerLegacyValues.put(key, cache.get(key)); + }); Queue restored = new ConcurrentLinkedQueue<>(); restored.addAll(changes); restored.addAll(cachedChanges); cachedChanges = restored; // A direct replacement may have temporarily exposed an older value while @@ -763,6 +1742,10 @@ private synchronized void requeueChanges(ArrayList changes) { throw recoveryFailure; } } + if (legacyVersions != null && cache != null) legacyVersions.forEach((key, version) -> { + if (newerLegacyValues.containsKey(key)) cache.put(key, newerLegacyValues.get(key)); + else if (Objects.equals(version, changedAt.get(key)) && inFlightValues.containsKey(key)) cache.put(key, inFlightValues.get(key)); + }); } private synchronized void finishInFlightBatch() { @@ -783,14 +1766,15 @@ private void scheduleImmediateSharedFlush() { private synchronized void scheduleChanges() { if (scheduled || cachedChanges == null || cachedChanges.isEmpty()) return; - manager.getPlugin().debug("Schedule changes"); scheduled = true; try { + manager.getPlugin().debug("Schedule changes"); manager.getTimer().schedule(() -> { try { processChanges(); } catch (Exception e) { manager.getPlugin().debug(e); } finally { onScheduledFlushComplete(); } }, 3, TimeUnit.SECONDS); } catch (RejectedExecutionException e) { scheduled = false; manager.getPlugin().debug(e); } + catch (RuntimeException | Error failure) { scheduled = false; throw failure; } } private synchronized void onScheduledFlushComplete() { @@ -800,17 +1784,30 @@ private synchronized void onScheduledFlushComplete() { public synchronized void updateCache(HashMap tempCache) { cache = tempCache == null ? new HashMap<>() : new HashMap<>(tempCache); - storageSnapshotPublished = true; + completeStorageSnapshot = false; + storageSnapshotPublished = true; hasEverPublishedStorageSnapshot = true; + hasPublishedStorageSnapshot = true; recordSnapshotReplacement(); } public synchronized void updateCachePreservingPending(HashMap storageValues) { + updateCachePreservingPending(storageValues, false); + } + + /** Called only after reading the complete persisted row. */ + public synchronized void updateCompleteCachePreservingPending(HashMap storageValues) { + updateCachePreservingPending(Objects.requireNonNull(storageValues, "storageValues"), true); + } + + private void updateCachePreservingPending(HashMap storageValues, boolean complete) { 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; + completeStorageSnapshot = complete; + storageSnapshotPublished = true; hasEverPublishedStorageSnapshot = true; + hasPublishedStorageSnapshot = true; recordSnapshotReplacement(); } @@ -818,48 +1815,114 @@ public synchronized void updateCachePreservingPending(HashMap public synchronized HashMap updateSharedSnapshot(HashMap values, long expectedVersion) { - return updateSharedSnapshot(values, expectedVersion, uuid, values != null && !values.isEmpty()); + return updateSharedSnapshot(values, expectedVersion, uuid, values != null && !values.isEmpty(), false); + } + + /** Publish a complete row read, retaining concurrent queued changes. */ + public synchronized HashMap updateCompleteSharedSnapshot(HashMap values, long expectedVersion) { + Objects.requireNonNull(values, "values"); + return updateSharedSnapshot(values, expectedVersion, uuid, !values.isEmpty(), true); + } + + /** Publish a strict legacy row only while its generation is still active. */ + public synchronized boolean tryPublishCompleteLegacySnapshot(HashMap values, long expectedVersion) { + Objects.requireNonNull(values, "values"); + if (sharedFlushGate != null || removing || legacyDirectWrites != 0 || uuid == null || cache == null || cachedChanges == null + || expectedVersion < 0 || expectedVersion > snapshotVersion || replacementVersion > expectedVersion) return false; + updateSharedSnapshot(values, expectedVersion, uuid, !values.isEmpty(), true); + return true; } private synchronized HashMap updateSharedSnapshot(HashMap values, - long expectedVersion, UUID expectedUuid, boolean snapshotStoredDataPresent) { + long expectedVersion, UUID expectedUuid, boolean snapshotStoredDataPresent, boolean complete) { 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); + if (legacyDirectWrites != 0 || replacementVersion > expectedVersion) return new HashMap<>(cache); storedDataPresent = snapshotStoredDataPresent; HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); // A flush may have claimed pending writes after the read started, removing // them from the queue before the storage snapshot can observe them. - merged.putAll(inFlightValues); - changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); - persistedAt.forEach((key, version) -> { - if ((version > expectedVersion || (!storageSnapshotPublished && version == expectedVersion)) - && cache.containsKey(key)) merged.put(key, cache.get(key)); + boolean sqliteAliases = sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE + || (sharedStorageType == null && legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + boolean mysqlAliases = sharedFlushGate == null && legacyMysqlFoldsColumns(); + persistedAt.entrySet().stream().sorted(java.util.Map.Entry.comparingByValue()).forEach(entry -> { + String key = entry.getKey(); long version = entry.getValue(); + if ((version > expectedVersion || (!hasPublishedStorageSnapshot && version == expectedVersion && cache.containsKey(key))) + && persistedValues.containsKey(key)) { + DataValue persisted = persistedValues.get(key); + if (key != null && (sqliteAliases || mysqlAliases)) for (String alias : merged.keySet()) + if (alias != null && (sqliteAliases ? sqliteDirectKey(alias).equals(sqliteDirectKey(key)) : alias.equalsIgnoreCase(key))) merged.put(alias, persisted); + merged.put(key, persisted); + } }); + inFlightValues.forEach((key, value) -> merged.put(key, resolveLegacyBulkValue(key, value))); + if (cachedChanges != null) for (UserDataChange pending : cachedChanges) merged.put(pending.getKey(), resolveLegacyBulkValue(pending.getKey(), pending.toUserDataValue(), legacyChangeVersions.get(pending))); + changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; - storageSnapshotPublished = true; + completeStorageSnapshot = complete; + storageSnapshotPublished = true; hasEverPublishedStorageSnapshot = true; + hasPublishedStorageSnapshot = true; recordSnapshotReplacement(); persistedAt.clear(); + persistedValues.clear(); return new HashMap<>(cache); } private void recordSnapshotReplacement() { + captureLegacyStorageType(); 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()))) - && changesAfterExclusiveFlush.stream().noneMatch(change -> key.equals(change.getKey()))); + && changesAfterExclusiveFlush.stream().noneMatch(change -> key.equals(change.getKey())) + && legacyChangesAfterCheckpoint.stream().noneMatch(change -> key.equals(change.getKey()))); if (cache != null) { // Queued writes precede changes accepted during an exclusive checkpoint. // Keep that ordering when a storage snapshot replaces the visible cache. if (cachedChanges != null) for (UserDataChange change : cachedChanges) if (storageSnapshotPublished || cache.containsKey(change.getKey())) - cache.put(change.getKey(), change.toUserDataValue()); + overlayLegacyPendingChange(change); for (UserDataChange change : changesAfterExclusiveFlush) if (storageSnapshotPublished || cache.containsKey(change.getKey())) - cache.put(change.getKey(), change.toUserDataValue()); + cache.put(change.getKey(), resolveLegacyBulkValue(change.getKey(), change.toUserDataValue())); + for (UserDataChange change : legacyChangesAfterCheckpoint) + if (storageSnapshotPublished || cache.containsKey(change.getKey())) + overlayLegacyPendingChange(change); + // A later direct write supersedes earlier staged entries even if the + // checkpoint replaces the visible snapshot before it completes. + cache.putAll(legacyCheckpointLatestValues); + sqliteCacheKeys.clear(); + HashMap sqliteCacheVersions = new HashMap(); + for (String key : cache.keySet()) { + if (key == null) continue; + String identity = sqliteDirectKey(key); + long version = changedAt.getOrDefault(key, 0L); + Long priorVersion = sqliteCacheVersions.get(identity); + if (priorVersion == null || version > priorVersion) { sqliteCacheKeys.put(identity, key); sqliteCacheVersions.put(identity, version); } + else if (version == priorVersion) { + String previous = sqliteCacheKeys.get(identity); + if (previous == null || cache.get(previous) != cache.get(key)) sqliteCacheKeys.put(identity, null); + } + } + } + } + private void overlayLegacyPendingChange(UserDataChange change) { + String key = change.getKey(); + DataValue value = resolveLegacyBulkValue(key, change.toUserDataValue(), legacyChangeVersions.get(change)); + cache.put(key, value); + if (key == null || sharedFlushGate != null) return; + boolean sqlite = legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE; + boolean mysqlFold = legacyMysqlFoldsColumns(); + if (!sqlite && !mysqlFold) return; + if (cache.size() > 2048) throw new IllegalStateException("Column identity is too large to reconcile safely"); + Long version = changedAt.get(key); + if (version == null) version = legacyChangeVersions.get(change); + for (String alias : cache.keySet()) if (alias != null && !alias.equals(key) + && (sqlite ? sqliteDirectKey(alias).equals(sqliteDirectKey(key)) : alias.equalsIgnoreCase(key))) { + cache.put(alias, value); + if (version != null) changedAt.put(alias, version); } } } 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 862264a5ac..99f259e7e9 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 @@ -64,6 +64,26 @@ public class UserDataManager { private volatile boolean sharedRuntimeRetiring; /** The one in-flight retirement, retained so lifecycle callers can await it safely. */ private volatile CompletionStage sharedRuntimeRetirement; + private int sharedIntegerAdmissions; + private boolean sharedIntegerClosing; + private boolean sharedIntegerOwnerReplacement; + private CompletableFuture sharedIntegerDrain = CompletableFuture.completedFuture(null); + private CompletableFuture sharedIntegerRetirement; + private final ThreadLocal sharedIntegerCompletion = new ThreadLocal<>(); + private final ThreadLocal sharedIntegerSynchronousDepth = ThreadLocal.withInitial(() -> 0); + private final ThreadLocal sharedIntegerWorker = new ThreadLocal<>(); + private final Map sharedIntegerQueuedUsers = new java.util.HashMap<>(); + private final Map sharedIntegerActiveUsers = new java.util.HashMap<>(); + private record SharedReplayLease(UUID uuid, String identity) { } + private static final class SharedReplayLeaseState { + final SharedUserDataRuntime runtime; int attempts; boolean committed; boolean checkpointed; + SharedReplayLeaseState(SharedUserDataRuntime runtime) { this.runtime = runtime; } + } + private final Map sharedReplayLeases = new java.util.HashMap<>(); + private final Set sharedIntegerCompletingUsers = new java.util.HashSet<>(); + private record DeferredSharedInteger(Runnable action, Consumer reject, + MutationReceipt receipt) { } + private final Map> sharedIntegerDeferred = new java.util.HashMap<>(); private final AtomicReference lastDeferredStorageFailure = new AtomicReference<>(); private final AtomicLong sharedNotificationGeneration = new AtomicLong(); private volatile boolean sharedNotificationsClosed; @@ -76,6 +96,129 @@ public class UserDataManager { private final Set sharedCachePopulations = ConcurrentHashMap.newKeySet(); private final Set completedSharedCachePopulations = ConcurrentHashMap.newKeySet(); private final ConcurrentHashMap sharedCachePopulationStates = new ConcurrentHashMap<>(); + private final ConcurrentHashMap legacyWriteStates = new ConcurrentHashMap<>(); + private final Object legacyMutationAdmission = new Object(); + private final java.util.concurrent.locks.ReentrantLock[] legacyMutationStripes = + java.util.stream.IntStream.range(0, 64).mapToObj(ignored -> new java.util.concurrent.locks.ReentrantLock()) + .toArray(java.util.concurrent.locks.ReentrantLock[]::new); + private final Map> pendingLegacyIntegers = new java.util.HashMap<>(); + private final Set legacyIntegerCacheRetirements = new java.util.HashSet<>(); + private final ThreadLocal legacyIntegerNotification = new ThreadLocal<>(); + private int pendingLegacyIntegerCount; + private static final int MAX_PENDING_LEGACY_INTEGERS = 256; + private static final int MAX_LEGACY_MUTATION_ATTEMPTS = 200; + private java.util.concurrent.locks.ReentrantLock legacyMutationStripe(UUID uuid) { + return legacyMutationStripes[(uuid.hashCode() & Integer.MAX_VALUE) % legacyMutationStripes.length]; + } + /** The accepted mutation owns its outcome; observers cannot cancel or settle it. */ + private static final class MutationReceipt extends CompletableFuture { + private final CompletableFuture finalized = new CompletableFuture<>(); + private final UserDataManager sharedOwner; + private MutationReceipt() { this.sharedOwner = null; } + private MutationReceipt(UserDataManager sharedOwner) { this.sharedOwner = sharedOwner; } + @Override public boolean cancel(boolean mayInterruptIfRunning) { return false; } + @Override public boolean complete(T value) { return false; } + @Override public boolean completeExceptionally(Throwable failure) { return false; } + @Override public void obtrudeValue(T value) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public void obtrudeException(Throwable failure) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public CompletableFuture completeAsync(java.util.function.Supplier supplier) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public CompletableFuture completeAsync(java.util.function.Supplier supplier, java.util.concurrent.Executor executor) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public CompletableFuture orTimeout(long timeout, TimeUnit unit) { throw new UnsupportedOperationException("read-only receipt"); } + @Override public CompletableFuture completeOnTimeout(T value, long timeout, TimeUnit unit) { throw new UnsupportedOperationException("read-only receipt"); } + private void succeed(T value) { super.complete(value); } + private void fail(Throwable failure) { super.completeExceptionally(failure); } + } + private static final class PendingLegacyIntegerMutation { + final UUID uuid; + final AdvancedCorePlugin.UserStorageOwner owner; + final Map values; + final UserDataCache.IntegerMutation operation; + final Consumer> notification; + final MutationReceipt completion = new MutationReceipt<>(); + int attempts; + PendingLegacyIntegerMutation(UUID uuid, AdvancedCorePlugin.UserStorageOwner owner, Map values, + UserDataCache.IntegerMutation operation, Consumer> notification) { + this.uuid = uuid; this.owner = owner; this.values = Map.copyOf(values); + this.operation = operation; this.notification = notification; + } + } + private final Object legacyRetirementAdmission = new Object(); + private int legacyDirectAdmissions; + private boolean legacyRetirementActive; + private boolean legacyRetirementRequested; + private boolean legacyIntegerOwnerReplacement; + private boolean legacyIntegerShutdown; + private int activeLegacyIntegerApplications; + private CompletableFuture legacyIntegerApplicationDrain = CompletableFuture.completedFuture(null); + private final ThreadLocal legacyIntegerOwnerReplacementThread = new ThreadLocal<>(); + private CompletableFuture legacyDirectDrain = CompletableFuture.completedFuture(null); + private CompletableFuture legacyRetirementWait; + private Throwable legacyDirectFailure; + private long legacyDirectSequence; + private final java.util.Map> legacyDirectSuccesses = new java.util.HashMap<>(); + private final java.util.Map legacyDirectAdmissionsByUser = new java.util.HashMap<>(); + private final java.util.Map> legacyUnrecoveredWrites = new java.util.HashMap<>(); + private static final class LegacyWriteState { + private long version; private int readers; private int writers; + private final java.util.IdentityHashMap confirmedTransitions = new java.util.IdentityHashMap<>(); + private final java.util.IdentityHashMap> confirmedKeyVersions = new java.util.IdentityHashMap<>(); + } + static final class LegacyRead { + private final UUID uuid; + private final LegacyWriteState state; + private long version; + private int nestedWriters; + private int pendingOverlapWriters; + private boolean pendingOverlapAuthorized; + private boolean active = true; + private final Set confirmedOverlap = new java.util.HashSet<>(); + private final Set filteredOverlap = new java.util.HashSet<>(); + private long confirmedOverlapVersion = -1; + private long confirmedClaimSerial; + private long lastConfirmedClaimVersion = -1; + private Object historyProvider; + private Object historySource; + private long historyVersion = -1; + private long historyTransitions; + private int historyMode; + private final boolean writing; + private final LegacyDirectWrite callback; + private LegacyRead(UUID uuid, LegacyWriteState state, long version, boolean writing, LegacyDirectWrite callback) { + this.uuid = uuid; this.state = state; this.version = version; this.writing = writing; this.callback = callback; + } + } + public static final class LegacyDirectWrite { + private final UUID uuid; + private final String sqliteRow; + private final UserDataCache cache; + private final LegacyWriteState state; + private final Set keys; + private final long sequence; + private volatile long cacheVersion; + private volatile UserDataCache.DirectCachePublication priorPublication; + private final java.util.concurrent.atomic.AtomicBoolean ended = new java.util.concurrent.atomic.AtomicBoolean(); + private final java.util.concurrent.atomic.AtomicBoolean failureRecorded = new java.util.concurrent.atomic.AtomicBoolean(); + private final java.util.concurrent.atomic.AtomicBoolean successful = new java.util.concurrent.atomic.AtomicBoolean(); + private final java.util.Map sqliteCommitPrevious = new java.util.HashMap<>(); + private Boolean sqliteInsertStatement; + private Boolean sqliteInsertChangesPrimary; + private Boolean sqliteUpdateChangesPrimary; + private LegacyDirectWrite redirected; + private final java.util.Map destinationGuards = new java.util.HashMap<>(); + private final java.util.Set possibleDestinations = new java.util.HashSet<>(); + private boolean borrowedBatch; + private LegacyRead nestedRead; + private LegacyRead overlapRead; + private final Set confirmedKeys = new java.util.HashSet<>(); + private Object confirmedProvider; + private boolean confirmedMysqlFold; + private com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL capturedNestedMysql; + private com.bencodez.advancedcore.api.user.userstorage.sql.UserTable capturedNestedSqlite; + private LegacyDirectWrite(UUID uuid, String sqliteRow, UserDataCache cache, LegacyWriteState state, Set keys, long sequence, long cacheVersion) { + this.uuid = uuid; this.sqliteRow = sqliteRow; this.cache = cache; this.state = state; this.keys = new java.util.HashSet<>(keys); this.sequence = sequence; this.cacheVersion = cacheVersion; + } + private String identity() { return sqliteRow == null ? "UUID:" + uuid : "SQLITE:" + sqliteRow; } + } /** Live storage identities, maintained from platform join/quit events. */ // Generation-stamped offline tombstones prevent an older queued platform // snapshot from erasing a newer quit and letting a later stale snapshot @@ -207,7 +350,10 @@ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Cons 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); + synchronized (sharedBindingAdmission) { + if (legacyBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); + sharedSqlRoute = new SharedSqlRoute(backend, owner, lifecycleGate, gate, exclusiveGate); + } } /** Compatibility overload for adapters that only need the global lifecycle gate. */ @@ -223,6 +369,10 @@ public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { } public final boolean hasSharedSqlBackend() { return sharedSqlRoute != null; } + public final boolean hasAuthoritativeSharedRowReads() { + SharedSqlRoute route = sharedSqlRoute; + return route != null && route.backend().hasAuthoritativeRowReads(); + } /** Whether the active shared writer owns the requested physical store. */ public final boolean usesSharedSqlStorage(com.bencodez.advancedcore.api.user.UserStorage storage) { @@ -342,6 +492,8 @@ public final synchronized void bindSharedRuntime(SharedUserDataRuntime runtime) if (sharedRuntime != null && !sharedRuntime.isClosed()) { throw new IllegalStateException("Shared user runtime is already bound"); } + if (sharedIntegerAdmissions != 0) throw new IllegalStateException("Shared integer mutations have not settled"); + sharedIntegerClosing = false; sharedRuntime = runtime; } @@ -393,6 +545,10 @@ public final CompletionStage replaceSharedSqlBackendAsync(SqlUserBackend r "Shared user storage is retiring and cannot be reloaded")); return completion; } + if (sharedIntegerClosing || sharedIntegerOwnerReplacement || sharedIntegerAdmissions != 0) { + completion.completeExceptionally(new IllegalStateException("Shared integer mutations have not settled")); + return completion; + } if (sharedRuntimeReplacement != null && !sharedRuntimeReplacement.toCompletableFuture().isDone()) { completion.completeExceptionally(new IllegalStateException("Shared user storage reload is already in progress")); return completion; @@ -454,9 +610,48 @@ public final boolean closeSharedRuntimeAsync(Runnable afterRetirement) { */ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable afterRetirement) { Objects.requireNonNull(afterRetirement, "afterRetirement"); + if (Boolean.TRUE.equals(sharedIntegerCompletion.get()) || sharedIntegerSynchronousDepth.get() != 0) + return CompletableFuture.failedFuture(new IllegalStateException("Shared retirement cannot await its own mutation completion")); SharedUserDataRuntime runtime; CompletableFuture completion; synchronized (this) { + if (sharedIntegerOwnerReplacement) + return CompletableFuture.failedFuture(new IllegalStateException("Shared storage owner replacement is active")); + if (sharedIntegerRetirement != null) return sharedIntegerRetirement.copy(); + if (sharedIntegerAdmissions != 0) { + sharedIntegerClosing = true; + CompletableFuture waiting = new CompletableFuture(); + sharedIntegerRetirement = waiting; + sharedIntegerDrain.whenComplete((ignored, failure) -> { + try { + timer.execute(() -> { + try { + CompletionStage retirement; + synchronized (UserDataManager.this) { + sharedIntegerRetirement = null; + retirement = closeSharedRuntimeAsyncCompletion(afterRetirement); + } + if (retirement == null) waiting.completeExceptionally(new IllegalStateException("Shared runtime disappeared during retirement")); + else retirement.whenComplete((done, error) -> { + if (error == null) waiting.complete(null); else waiting.completeExceptionally(error); + }); + } catch (RuntimeException | Error error) { + synchronized (UserDataManager.this) { + if (!sharedRuntimeRetiring && sharedRuntime != null) sharedIntegerClosing = false; + } + waiting.completeExceptionally(error); + } + }); + } catch (RuntimeException | Error error) { + synchronized (UserDataManager.this) { + if (sharedIntegerRetirement == waiting) sharedIntegerRetirement = null; + sharedIntegerClosing = false; + } + waiting.completeExceptionally(error); + } + }); + return waiting.copy(); + } if (sharedRuntimeReplacing) { CompletionStage replacement = sharedRuntimeReplacement; // A failed replacement leaves the old runtime and native owner alive. @@ -470,6 +665,7 @@ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable af // A second shutdown caller must not interpret an in-flight retirement as // "no runtime" and close the native provider underneath its final flush. if (runtime == null) return sharedRuntimeRetiring ? sharedRuntimeRetirement : null; + sharedIntegerClosing = true; // Fence queued notifications before closeAsync marks the runtime retiring. // An already-running callback retains its lifecycle read admission and // finishes before the runtime's final writer can close storage. @@ -479,11 +675,23 @@ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable af completion = new CompletableFuture<>(); sharedRuntimeRetirement = completion; } - runtime.closeAsync(timer).whenComplete((ignored, failure) -> { + CompletionStage runtimeClose; + try { runtimeClose = Objects.requireNonNull(runtime.closeAsync(timer), "runtime close stage"); } + catch (RuntimeException | Error failure) { + synchronized (this) { + if (sharedRuntime == null) sharedRuntime = runtime; + sharedRuntimeRetiring = false; + sharedIntegerClosing = false; + } + completion.completeExceptionally(failure); + return completion; + } + runtimeClose.whenComplete((ignored, failure) -> { if (failure != null) { synchronized (this) { if (sharedRuntime == null) sharedRuntime = runtime; sharedRuntimeRetiring = false; + sharedIntegerClosing = false; } reportDeferredStorageFailure(failure); completion.completeExceptionally(failure); @@ -734,14 +942,92 @@ private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall, CacheRefresh try { throw new Exception("caching here: " + uuid.toString()); } catch (Exception e) { e.printStackTrace(); } } - if (userDataCache.containsKey(uuid)) { - UserDataCache data = userDataCache.get(uuid); - refresh.flushNotification = data.clearChangesForRefresh(); - refresh.changed = data.refreshInternal(false); - } else { - UserDataCache data = new UserDataCache(this, uuid); - refresh.changed = data.refreshInternal(false); - if (data.hasCache()) userDataCache.put(uuid, data); + if (hasSharedSqlBackend()) { + UserDataCache existing = userDataCache.get(uuid); + if (existing != null) { + refresh.flushNotification = existing.clearChangesForRefresh(); + refresh.changed = existing.refreshInternal(false, true); + } else { + UserDataCache data = new UserDataCache(this, uuid); + refresh.changed = data.refreshInternal(false, true); + if (data.hasCache()) userDataCache.putIfAbsent(uuid, data); + } + return; + } + UserDataCache candidate = new UserDataCache(this, uuid); + UserDataCache existing = userDataCache.putIfAbsent(uuid, candidate); + UserDataCache data = existing == null ? candidate : existing; + LegacyRead read = beginLegacyRead(uuid); + try { + if (read != null && read.writing) return; + if (existing != null) refresh.flushNotification = data.clearChangesForRefresh(); + refresh.changed = data.refreshInternal(false, false, read); + } finally { if (read != null) endLegacyRead(read); } + } + + LegacyRead beginLegacyRead(UUID uuid) { + LegacyRead[] result = new LegacyRead[1]; + LegacyDirectWrite callback = UserDataCache.currentLegacyCallback(this); + legacyWriteStates.compute(uuid, (id, previous) -> { + LegacyWriteState current = previous == null ? new LegacyWriteState() : previous; + current.readers++; + boolean allowed = callback != null && callback.uuid.equals(uuid) && callback.state == current && !callback.ended.get() && current.writers == 1; + result[0] = new LegacyRead(uuid, current, current.version, current.writers != 0 && !allowed, allowed ? callback : null); + return current; + }); + return result[0]; + } + + void endLegacyRead(LegacyRead read) { + legacyWriteStates.compute(read.uuid, (id, current) -> { + if (current != read.state || current.readers <= 0) throw new IllegalStateException("Legacy read state changed"); + read.active = false; + current.readers--; + return current.writers == 0 && current.readers == 0 ? null : current; + }); + } + + T publishLegacyRead(LegacyRead read, Supplier publication) { + AtomicReference result = new AtomicReference<>(); + legacyWriteStates.computeIfPresent(read.uuid, (id, current) -> { + if (current == read.state && legacyReadWritersAllowed(read, current) && current.version == read.version) + result.set(publication.get()); + return current; + }); + return result.get(); + } + + boolean isCurrentLegacyRead(LegacyRead read) { + boolean[] current = new boolean[1]; + legacyWriteStates.computeIfPresent(read.uuid, (id, state) -> { + current[0] = state == read.state && legacyReadWritersAllowed(read, state) && state.version == read.version; + return state; + }); + return current[0]; + } + private boolean legacyReadWritersAllowed(LegacyRead read, LegacyWriteState state) { + return state.writers == 0 || (!read.writing && read.callback == null && read.nestedWriters > 0 && state.writers == read.nestedWriters) + || (read.pendingOverlapAuthorized && read.pendingOverlapWriters > 0 && state.writers == read.pendingOverlapWriters) + || (state.writers == 1 && read.callback != null && !read.callback.ended.get() + && UserDataCache.currentLegacyCallback(this) == read.callback); + } + + /** A completed batch inside a direct-write callback is ordered after that direct admission. */ + void recordCompletedLegacyCallbackBatch(LegacyRead read, String row, Set keys, UserStorage storage, boolean foldColumns) { + if (read == null || read.callback == null || storage == null || keys.isEmpty()) return; + LegacyDirectWrite callback = read.callback; + if (row == null || (storage == UserStorage.SQLITE ? !row.equals(callback.sqliteRow) + : callback.sqliteRow != null || !callback.uuid.toString().equalsIgnoreCase(row))) return; + synchronized (legacyRetirementAdmission) { + if (callback.ended.get()) return; + long sequence = ++legacyDirectSequence; + java.util.Map successes = legacyDirectSuccesses.computeIfAbsent(callback.identity(), ignored -> new java.util.HashMap<>()); + for (String key : keys) { + if (key == null) continue; + String storageKey = storage == UserStorage.SQLITE ? UserDataCache.sqliteDirectKey(key) : storage.name() + ":" + key; + successes.merge(storageKey, sequence, Math::max); + if (foldColumns && storage == UserStorage.MYSQL) successes.merge(mysqlFoldedKey(storageKey), sequence, Math::max); + } } } @@ -857,6 +1143,8 @@ private boolean finishSharedCachePopulation(SharedCachePopulation population, bo */ public final boolean retireSharedCache(UUID uuid, UserDataCache expected) { Objects.requireNonNull(uuid, "uuid"); + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegers.containsKey(uuid)) return false; SharedCachePopulationState state = sharedCachePopulationStates.computeIfAbsent(uuid, ignored -> new SharedCachePopulationState()); synchronized (state) { @@ -868,6 +1156,7 @@ public final boolean retireSharedCache(UUID uuid, UserDataCache expected) { if (state.activePopulations == 0) sharedCachePopulationStates.remove(uuid, state); return true; } + } } /** Publish an empty cache generation without storage access for synchronous callers. */ @@ -913,7 +1202,30 @@ private static final class CacheRefresh { private CacheRefresh(UUID uuid) { this.uuid = uuid; } } - public void cacheUserIfNeeded(UUID uuid) { if (!userDataCache.containsKey(uuid)) cacheUser(uuid); } + private final java.util.concurrent.ConcurrentHashMap deferredLegacyRefreshes = new java.util.concurrent.ConcurrentHashMap<>(); + public void cacheUserIfNeeded(UUID uuid) { + UserDataCache current = userDataCache.get(uuid); + if (current == null) cacheUser(uuid); + else if (!hasSharedSqlBackend() && !current.hasPublishedStorageSnapshot()) { + if (current.hasEverPublishedStorageSnapshot()) getCacheWithoutStorage(uuid); + else cacheUser(uuid); + } + } + /** Return the current cache without doing storage work on the caller. */ + public UserDataCache getCacheWithoutStorage(UUID uuid) { + UserDataCache current = userDataCache.get(uuid); + if (current == null) return null; + if (!hasSharedSqlBackend() && !current.hasPublishedStorageSnapshot() && deferredLegacyRefreshes.putIfAbsent(uuid, current) == null) { + try { + timer.execute(() -> { + try { if (userDataCache.get(uuid) == current && !current.hasPublishedStorageSnapshot()) populateCompleteLegacyCache(uuid, current); } + catch (RuntimeException | Error failure) { reportDeferredStorageFailure(failure); } + finally { deferredLegacyRefreshes.remove(uuid, current); } + }); + } catch (RejectedExecutionException failure) { deferredLegacyRefreshes.remove(uuid, current); reportDeferredStorageFailure(failure); } + } + return current; + } /** * Shared SQL writes are worker-only. Existing synchronous Bukkit clear/remove @@ -1113,8 +1425,55 @@ public void clearCache() { clearCacheNow(); } - /** Complete only after all cache entries have been flushed and retired. */ + /** Complete only after all cache entries have been flushed and retired. + * A request from a direct-write callback that must finish before its own write + * completes fails exceptionally; the caller may retry after the callback returns. + */ public CompletionStage clearCacheAsyncCompletion() { + if (UserDataCache.hasLiveLegacyCallback(this) || Boolean.TRUE.equals(legacyIntegerNotification.get())) + return CompletableFuture.failedFuture(new IllegalStateException("Cache retirement cannot await its own direct-write callback")); + CompletableFuture pending; + CompletableFuture waiting; + synchronized (legacyRetirementAdmission) { + if (legacyRetirementWait != null) return legacyRetirementWait.copy(); + java.util.ArrayList> dependencies = new java.util.ArrayList>(); + if (legacyDirectAdmissions != 0) dependencies.add(legacyDirectDrain); + if (activeLegacyIntegerApplications != 0) dependencies.add(legacyIntegerApplicationDrain); + synchronized (legacyMutationAdmission) { + for (java.util.ArrayDeque queue : pendingLegacyIntegers.values()) + for (com.bencodez.advancedcore.api.user.usercache.UserDataManager.PendingLegacyIntegerMutation mutation : queue) dependencies.add(mutation.completion); + } + pending = dependencies.isEmpty() ? null : CompletableFuture.allOf(dependencies.toArray(CompletableFuture[]::new)); + if (pending != null) { + legacyRetirementRequested = true; + legacyRetirementWait = new CompletableFuture<>(); + } + waiting = legacyRetirementWait; + } + if (pending != null) { + pending.whenComplete((ignored, failure) -> { + if (failure != null) { + synchronized (legacyRetirementAdmission) { legacyRetirementRequested = false; legacyRetirementWait = null; } + reportDeferredStorageFailure(failure); + waiting.completeExceptionally(failure); + return; + } + try { + timer.execute(() -> { + Throwable error = null; + try { clearCacheNow(); } + catch (RuntimeException | Error failureDuringRetirement) { error = failureDuringRetirement; reportDeferredStorageFailure(error); } + finally { synchronized (legacyRetirementAdmission) { if (legacyRetirementWait == waiting) { legacyRetirementWait = null; legacyRetirementRequested = false; } } } + if (error == null) waiting.complete(null); else waiting.completeExceptionally(error); + }); + } catch (RejectedExecutionException rejected) { + synchronized (legacyRetirementAdmission) { legacyRetirementRequested = false; legacyRetirementWait = null; } + reportDeferredStorageFailure(rejected); + waiting.completeExceptionally(rejected); + } + }); + return waiting.copy(); + } CompletableFuture completion = new CompletableFuture<>(); if (!mustDeferSharedStorageAccess()) { try { @@ -1132,8 +1491,8 @@ public CompletionStage clearCacheAsyncCompletion() { clearCacheNow(); completion.complete(null); } catch (RuntimeException | Error failure) { - reportDeferredStorageFailure(failure); - completion.completeExceptionally(failure); + try { reportDeferredStorageFailure(failure); } + finally { completion.completeExceptionally(failure); } } }); } catch (RejectedExecutionException rejected) { @@ -1144,6 +1503,21 @@ public CompletionStage clearCacheAsyncCompletion() { } private void clearCacheNow() { + synchronized (legacyRetirementAdmission) { + if (legacyDirectAdmissions != 0) throw new IllegalStateException("Legacy direct writes are still pending"); + if (activeLegacyIntegerApplications != 0) throw new IllegalStateException("Legacy integer mutations are active"); + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegerCount != 0) throw new IllegalStateException("Legacy integer mutations are still pending"); + } + if (!legacyUnrecoveredWrites.isEmpty()) throw new IllegalStateException("A failed uncached legacy write requires an explicit successful retry"); + if (legacyRetirementActive) throw new IllegalStateException("Legacy cache retirement is already active"); + legacyRetirementActive = true; + } + try { clearCacheNowAdmitted(); } + finally { synchronized (legacyRetirementAdmission) { legacyRetirementActive = false; if (legacyRetirementWait == null) legacyRetirementRequested = false; } } + } + + private void clearCacheNowAdmitted() { // 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. @@ -1292,6 +1666,11 @@ private void clearNonNeededCachedUsers(Set onlineSnapshot) { if (isUserOnline(uuid)) return; UserDataCache current = userDataCache.get(uuid); if (current != expected || current.getSharedSnapshotVersion() != expectedVersion) return; + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegers.containsKey(uuid)) return; + legacyIntegerCacheRetirements.add(uuid); + } + try { current.beginRemoval(); if (isUserOnline(uuid)) { current.cancelRemoval(); @@ -1327,6 +1706,7 @@ private void clearNonNeededCachedUsers(Set onlineSnapshot) { boolean cacheRemoved = retireSharedCache(uuid, current); retired.set(cacheRemoved); } + } finally { synchronized (legacyMutationAdmission) { legacyIntegerCacheRetirements.remove(uuid); } } }); Consumer listener = sharedCacheRemovalListener; if (retired.get() && listener != null) listener.accept(uuid); @@ -1344,6 +1724,1051 @@ public UserDataCache getPublishedCache(UUID uuid) { UserDataCache cache = userDataCache.get(uuid); return cache != null && cache.hasPublishedStorageSnapshot() ? cache : null; } + + /** Resolve a legacy generation without waiting for storage or lifecycle work. */ + public UserDataCache.IntegerMutationResult tryMutateLegacyIntegers(UUID uuid, Map values, + UserDataCache.IntegerMutation operation, Consumer> notification) { + if (hasSharedSqlBackend()) return null; + Objects.requireNonNull(uuid, "uuid"); + UserDataCache.IntegerMutationResult mutation; + java.util.concurrent.locks.ReentrantLock stripe = legacyMutationStripe(uuid); + boolean locked = stripe.tryLock(); + if (!locked && !isPlatformOwnedThread()) { + try { locked = stripe.tryLock(100, TimeUnit.MILLISECONDS); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); return null; } + } + if (!locked) return null; + boolean admitted = false; + try { + try { + synchronized (legacyRetirementAdmission) { + if (legacyRetirementActive || legacyRetirementRequested || legacyIntegerOwnerReplacement || legacyIntegerShutdown) return null; + if (activeLegacyIntegerApplications++ == 0) legacyIntegerApplicationDrain = new CompletableFuture<>(); + admitted = true; + } + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegers.containsKey(uuid) || legacyIntegerCacheRetirements.contains(uuid)) return null; + } + mutation = tryMutateLegacyIntegersAdmitted(uuid, values, operation); + } finally { stripe.unlock(); } + boolean nestedNotification = Boolean.TRUE.equals(legacyIntegerNotification.get()); + legacyIntegerNotification.set(true); + try { notifyLegacyIntegerMutation(mutation, operation, notification); } + catch (RuntimeException | Error notificationFailure) { + try { reportDeferredStorageFailure(notificationFailure); } catch (RuntimeException | Error ignored) { } + } + finally { if (nestedNotification) legacyIntegerNotification.set(true); else legacyIntegerNotification.remove(); } + return mutation; + } finally { + CompletableFuture drained = null; + if (admitted) synchronized (legacyRetirementAdmission) { + if (--activeLegacyIntegerApplications == 0) drained = legacyIntegerApplicationDrain; + } + if (drained != null) drained.complete(null); + } + } + + private UserDataCache.IntegerMutationResult tryMutateLegacyIntegersAdmitted(UUID uuid, Map values, + UserDataCache.IntegerMutation operation) { + AtomicReference result = new AtomicReference<>(); + legacyWriteStates.compute(uuid, (id, state) -> { + if (state == null || state.writers == 0) { + UserDataCache cache = userDataCache.get(id); + if (cache != null) result.set(cache.tryMutateLegacyIntegers(values, operation, null)); + } + return state; + }); + return result.get(); + } + + private void notifyLegacyIntegerMutation(UserDataCache.IntegerMutationResult mutation, + UserDataCache.IntegerMutation operation, Consumer> notification) { + if (mutation != null && mutation.applied() && notification != null) { + java.util.LinkedHashSet changed = new java.util.LinkedHashSet(); + for (java.util.Map.Entry entry : mutation.previous().entrySet()) + if (operation != UserDataCache.IntegerMutation.DEBIT_POSITIVE || entry.getValue() > 0) changed.add(entry.getKey()); + notification.accept(Set.copyOf(changed)); + } + } + + /** + * Retain one original arithmetic operation for a legacy storage worker. + * A terminal exceptional result means this operation was not applied and will + * not be applied later. The returned receipt cannot be cancelled or externally + * completed; callers must retain and observe it for the actual operation outcome. + * Cancelling a dependent stage only cancels that observation, not this receipt + * or the mutation, and must not be treated as a reason to retry the mutation. + */ + public CompletionStage submitLegacyIntegerMutation(UUID uuid, + Map values, UserDataCache.IntegerMutation operation, Consumer> notification) { + Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(values, "values"); Objects.requireNonNull(operation, "operation"); + if (values.isEmpty() || values.size() > 64) throw new IllegalArgumentException("Invalid integer mutation size"); + com.bencodez.advancedcore.AdvancedCorePlugin.UserStorageOwner owner = Objects.requireNonNull(plugin.getNativeUserStorageOwner(), "Native user storage owner is unavailable"); + PendingLegacyIntegerMutation pending = new PendingLegacyIntegerMutation(uuid, owner, values, operation, notification); + boolean start; + java.util.concurrent.locks.ReentrantLock stripe = legacyMutationStripe(uuid); + if (!stripe.tryLock()) throw new RejectedExecutionException("Legacy integer mutation admission is busy"); + try { + synchronized (legacyRetirementAdmission) { + if (legacyRetirementActive || legacyRetirementRequested || legacyIntegerOwnerReplacement || legacyIntegerShutdown) + throw new IllegalStateException("Legacy cache retirement or owner replacement is active"); + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegerCount >= MAX_PENDING_LEGACY_INTEGERS) throw new RejectedExecutionException("Legacy integer mutation queue is full"); + if (legacyIntegerCacheRetirements.contains(uuid)) throw new IllegalStateException("Legacy user cache is retiring"); + beginLegacyCacheBatch(); + java.util.ArrayDeque queue = pendingLegacyIntegers.computeIfAbsent(uuid, ignored -> new java.util.ArrayDeque<>()); + start = queue.isEmpty(); queue.addLast(pending); pendingLegacyIntegerCount++; + } + } + } finally { stripe.unlock(); } + if (start) scheduleLegacyIntegerMutation(pending, 0); + return pending.completion; + } + + /** Submit one shared-cache mutation and retain its receipt through lifecycle retirement. */ + public CompletionStage submitSharedIntegerMutation(UUID uuid, + Map values, UserDataCache.IntegerMutation operation, Consumer> notification) { + Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(values, "values"); Objects.requireNonNull(operation, "operation"); + if (values.isEmpty() || values.size() > 64) throw new IllegalArgumentException("Invalid integer mutation size"); + java.util.Map operands = Map.copyOf(values); + MutationReceipt receipt = new MutationReceipt(this); + SharedUserDataRuntime runtime; + Throwable schedulingFailure = null; + synchronized (this) { + runtime = sharedRuntime; + if (runtime == null || sharedRuntimeRetiring || sharedRuntimeReplacing || sharedIntegerClosing || sharedIntegerOwnerReplacement) + throw new RejectedExecutionException("Shared user storage is unavailable or retiring"); + if (sharedIntegerAdmissions >= MAX_PENDING_LEGACY_INTEGERS) throw new RejectedExecutionException("Shared integer mutation queue is full"); + if (sharedIntegerAdmissions++ == 0) sharedIntegerDrain = new CompletableFuture<>(); + sharedIntegerQueuedUsers.merge(uuid, 1, Integer::sum); + DeferredSharedInteger deferred = new DeferredSharedInteger( + () -> runSharedIntegerMutation(runtime, uuid, operands, operation, notification, receipt), + failure -> finishSharedIntegerMutation(uuid, receipt, null, failure), receipt); + java.util.ArrayDeque queue = sharedIntegerDeferred.computeIfAbsent(uuid, ignored -> new java.util.ArrayDeque<>()); + queue.addLast(deferred); + if (queue.size() == 1 && !sharedIntegerActiveUsers.containsKey(uuid) && !sharedIntegerCompletingUsers.contains(uuid)) try { timer.execute(deferred.action()); } + catch (RuntimeException | Error failure) { schedulingFailure = failure; } + } + if (schedulingFailure != null) finishSharedIntegerMutation(uuid, receipt, null, schedulingFailure); + return receipt; + } + + private void runSharedIntegerMutation(SharedUserDataRuntime runtime, UUID uuid, Map values, + UserDataCache.IntegerMutation operation, Consumer> notification, + MutationReceipt receipt) { + try { + sharedIntegerWorker.set(uuid); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutationResult result = runtime.withStorageReadAdmission(() -> { + if (sharedRuntime != runtime) throw new IllegalStateException("Shared storage owner changed"); + com.bencodez.advancedcore.api.user.usercache.UserDataCache cache = getPublishedCache(uuid); + if (cache == null) { cacheUser(uuid); cache = getPublishedCache(uuid); } + if (cache == null) throw new IllegalStateException("Shared user cache is unavailable"); + for (String key : values.keySet()) if (cache.getIfPresent(key) == null) cache.seedMissingIntegerFromCompleteSnapshot(key); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutationResult applied = cache.tryMutateIntegers(values, operation, notification); + if (applied == null) throw new IllegalStateException("Shared integer mutation was not applied"); + return applied; + }); + finishSharedIntegerMutation(uuid, receipt, result, null); + } catch (UserDataCache.SharedIntegerMutationUncertainException uncertain) { + try { reportDeferredStorageFailure(uncertain); } catch (RuntimeException | Error ignored) { } + } catch (RuntimeException | Error failure) { finishSharedIntegerMutation(uuid, receipt, null, failure); } + finally { sharedIntegerWorker.remove(); } + } + + private void finishSharedIntegerMutation(UUID uuid, MutationReceipt receipt, T result, Throwable failure) { + synchronized (this) { + java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); + if (queue == null || queue.isEmpty() || queue.peekFirst().receipt() != receipt) + throw new IllegalStateException("Shared integer completion is out of order"); + queue.removeFirst(); + if (queue.isEmpty()) sharedIntegerDeferred.remove(uuid); + sharedIntegerCompletingUsers.add(uuid); + sharedIntegerQueuedUsers.computeIfPresent(uuid, (key, count) -> count == 1 ? null : count - 1); + } + sharedIntegerCompletion.set(true); + try { if (failure == null) receipt.succeed(result); else receipt.fail(failure); } + finally { sharedIntegerCompletion.remove(); } + DeferredSharedInteger failedTask = null; Throwable schedulingFailure = null; + synchronized (this) { + sharedIntegerCompletingUsers.remove(uuid); + java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); + if (queue != null && !queue.isEmpty() && !sharedIntegerActiveUsers.containsKey(uuid)) { + com.bencodez.advancedcore.api.user.usercache.UserDataManager.DeferredSharedInteger next = queue.peekFirst(); + try { timer.execute(next.action()); } + catch (RuntimeException | Error error) { failedTask = next; schedulingFailure = error; } + } + if (--sharedIntegerAdmissions == 0) sharedIntegerDrain.complete(null); + } + if (failedTask != null) failedTask.reject().accept(schedulingFailure); + receipt.finalized.complete(null); + } + + /** Observe when a shared mutation's completion callbacks and admission have settled. */ + public CompletionStage afterSharedMutationFinalization(CompletionStage completion) { + if (!(completion instanceof MutationReceipt receipt) || receipt.sharedOwner != this) + throw new IllegalArgumentException("Not a shared mutation receipt"); + return receipt.finalized.minimalCompletionStage(); + } + + /** + * Submit an idempotent SQL replay transaction. The work must atomically + * record and check the same durable operation identity on every attempt, and + * must not perform external side effects. An uncertain commit is retried with + * the same work; the original non-cancellable receipt remains pending until + * the transaction can establish its outcome. A callback may throw + * {@link SqlUserStorage.ReplayPreconditionException} only before any write or + * external effect to report a definite rejection without retry. + */ + public CompletionStage submitSharedReplayTransaction(UUID uuid, UserStorage storage, + Map initialValues, SqlUserStorage.TransactionWork work) { + return submitSharedReplayTransaction(uuid, storage, initialValues, work, null); + } + + /** Submit checkpoint cleanup for an existing lease even while shutdown is draining it. */ + public CompletionStage submitSharedReplayCheckpoint(UUID uuid, UserStorage storage, String identity, + Map initialValues, SqlUserStorage.TransactionWork work) { + return submitSharedReplayTransaction(uuid, storage, initialValues, work, Objects.requireNonNull(identity, "identity")); + } + + private CompletionStage submitSharedReplayTransaction(UUID uuid, UserStorage storage, + Map initialValues, SqlUserStorage.TransactionWork work, String checkpointIdentity) { + Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(storage, "storage"); + Objects.requireNonNull(work, "work"); java.util.Map initial = Map.copyOf(initialValues); + MutationReceipt receipt = new MutationReceipt(this); SharedUserDataRuntime runtime; Throwable schedulingFailure = null; + synchronized (this) { + runtime = sharedRuntime; + com.bencodez.advancedcore.api.user.usercache.UserDataManager.SharedReplayLeaseState checkpointLease = checkpointIdentity == null ? null : sharedReplayLeases.get(new SharedReplayLease(uuid, checkpointIdentity)); + boolean drainingCheckpoint = checkpointLease != null && runtime != null && checkpointLease.runtime == runtime; + if (runtime == null || sharedRuntimeRetiring || sharedRuntimeReplacing || (sharedIntegerClosing && !drainingCheckpoint) || sharedIntegerOwnerReplacement) + throw new RejectedExecutionException("Shared user storage is unavailable or retiring"); + if (!runtime.backend().supportsAtomicUserTransactions()) + throw new UnsupportedOperationException("Shared provider does not support atomic replay transactions"); + // Reserve bounded cleanup capacity so a full set of retained replay + // leases cannot prevent the checkpoints that release those leases. + int limit = drainingCheckpoint ? MAX_PENDING_LEGACY_INTEGERS * 2 : MAX_PENDING_LEGACY_INTEGERS; + if (sharedIntegerAdmissions >= limit) throw new RejectedExecutionException("Shared mutation queue is full"); + if (sharedIntegerAdmissions++ == 0) sharedIntegerDrain = new CompletableFuture<>(); + sharedIntegerQueuedUsers.merge(uuid, 1, Integer::sum); + DeferredSharedInteger deferred = new DeferredSharedInteger( + () -> runSharedReplayTransaction(runtime, uuid, storage, initial, work, receipt, 0), + failure -> finishSharedIntegerMutation(uuid, receipt, null, failure), receipt); + java.util.ArrayDeque queue = sharedIntegerDeferred.computeIfAbsent(uuid, ignored -> new java.util.ArrayDeque<>()); + queue.addLast(deferred); + if (queue.size() == 1 && !sharedIntegerActiveUsers.containsKey(uuid) && !sharedIntegerCompletingUsers.contains(uuid)) + try { timer.execute(deferred.action()); } catch (RuntimeException | Error failure) { schedulingFailure = failure; } + } + if (schedulingFailure != null) finishSharedIntegerMutation(uuid, receipt, null, schedulingFailure); + return receipt; + } + + /** Retain the current owner from award admission through durable checkpoint cleanup. */ + public synchronized void acquireSharedReplayLease(UUID uuid, String identity) { acquireSharedReplayLease(uuid, identity, false); } + public synchronized void acquireSharedReplayCheckpointLease(UUID uuid, String identity) { acquireSharedReplayLease(uuid, identity, true); } + /** Reject an admitted award overtaken by durable checkpoint cleanup before it writes. */ + public synchronized void requireSharedReplayAwardActive(UUID uuid, String identity) { + com.bencodez.advancedcore.api.user.usercache.UserDataManager.SharedReplayLeaseState state = sharedReplayLeases.get(new SharedReplayLease(Objects.requireNonNull(uuid), Objects.requireNonNull(identity))); + if (state == null || state.runtime != sharedRuntime || state.checkpointed) + throw new SqlUserStorage.ReplayPreconditionException("Replay award has already been checkpointed"); + } + private void acquireSharedReplayLease(UUID uuid, String identity, boolean checkpoint) { + Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(identity, "identity"); + if (identity.isEmpty() || identity.length() > 4096) throw new IllegalArgumentException("Invalid replay identity"); + SharedReplayLease key = new SharedReplayLease(uuid, identity); com.bencodez.advancedcore.api.user.usercache.UserDataManager.SharedReplayLeaseState existing = sharedReplayLeases.get(key); + if (existing != null) { + if (existing.runtime != sharedRuntime) throw new IllegalStateException("Replay owner changed"); + if (existing.checkpointed && !checkpoint) throw new IllegalStateException("Replay award is already checkpointed"); + existing.attempts++; + return; + } + if (sharedRuntime == null || sharedRuntimeRetiring || sharedRuntimeReplacing || sharedIntegerClosing || sharedIntegerOwnerReplacement) + throw new RejectedExecutionException("Shared user storage is unavailable or retiring"); + if (sharedIntegerAdmissions >= MAX_PENDING_LEGACY_INTEGERS) throw new RejectedExecutionException("Shared mutation queue is full"); + if (sharedIntegerAdmissions++ == 0) sharedIntegerDrain = new CompletableFuture<>(); + SharedReplayLeaseState state = new SharedReplayLeaseState(sharedRuntime); state.attempts = 1; sharedReplayLeases.put(key, state); + } + + /** Settle one lease attempt; a committed award retains the owner until checkpoint cleanup. */ + public void settleSharedReplayLease(UUID uuid, String identity, boolean awardCommitted, boolean checkpointCommitted) { + Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(identity, "identity"); + synchronized (this) { + SharedReplayLease key = new SharedReplayLease(uuid, identity); com.bencodez.advancedcore.api.user.usercache.UserDataManager.SharedReplayLeaseState state = sharedReplayLeases.get(key); + if (state == null || state.attempts <= 0) throw new IllegalStateException("Replay lease attempt is not active"); + state.attempts--; state.committed |= awardCommitted; state.checkpointed |= checkpointCommitted; + if (state.attempts == 0 && (!state.committed || state.checkpointed)) { + sharedReplayLeases.remove(key); + if (--sharedIntegerAdmissions == 0) sharedIntegerDrain.complete(null); + } + } + } + + private void runSharedReplayTransaction(SharedUserDataRuntime runtime, UUID uuid, UserStorage storage, + Map initial, SqlUserStorage.TransactionWork work, MutationReceipt receipt, int attempts) { + T result; + try { + if (sharedRuntime != runtime) throw new IllegalStateException("Shared storage owner changed"); + result = runtime.transaction(uuid, storage, initial, work); + } catch (SqlUserStorage.ReplayPreconditionException rejected) { + finishSharedIntegerMutation(uuid, receipt, null, rejected); + return; + } catch (RuntimeException failure) { + if (attempts == 0 || (attempts & (attempts - 1)) == 0) + try { reportDeferredStorageFailure(failure); } catch (RuntimeException | Error ignored) { } + int next = attempts == Integer.MAX_VALUE ? attempts : attempts + 1; + long delay = Math.min(30000L, 250L << Math.min(attempts, 7)); + try { timer.schedule(() -> runSharedReplayTransaction(runtime, uuid, storage, initial, work, receipt, next), delay, TimeUnit.MILLISECONDS); } + catch (RuntimeException | Error rejected) { try { reportDeferredStorageFailure(rejected); } catch (RuntimeException | Error ignored) { } } + return; + } catch (Error uncertain) { try { reportDeferredStorageFailure(uncertain); } catch (RuntimeException | Error ignored) { } return; } + finishSharedIntegerMutation(uuid, receipt, result, null); + } + + /** Nonblocking admission prevents a synchronous writer overtaking a queued award. */ + synchronized boolean beginSharedSynchronousIntegerMutation(UUID uuid) { + boolean worker = uuid.equals(sharedIntegerWorker.get()) && sharedIntegerSynchronousDepth.get() == 0 + && !Boolean.TRUE.equals(sharedIntegerCompletion.get()); + if (!worker && (sharedIntegerClosing || sharedIntegerOwnerReplacement || sharedIntegerQueuedUsers.containsKey(uuid))) return false; + if (sharedIntegerAdmissions++ == 0) sharedIntegerDrain = new CompletableFuture<>(); + sharedIntegerSynchronousDepth.set(sharedIntegerSynchronousDepth.get() + 1); + sharedIntegerActiveUsers.merge(uuid, 1, Integer::sum); + return true; + } + void endSharedSynchronousIntegerMutation(UUID uuid) { + java.util.ArrayList failures = new java.util.ArrayList(); + synchronized (this) { + int depth = sharedIntegerSynchronousDepth.get() - 1; + if (depth == 0) sharedIntegerSynchronousDepth.remove(); else sharedIntegerSynchronousDepth.set(depth); + sharedIntegerActiveUsers.computeIfPresent(uuid, (key, count) -> count == 1 ? null : count - 1); + if (!sharedIntegerActiveUsers.containsKey(uuid) && !sharedIntegerCompletingUsers.contains(uuid) + && !uuid.equals(sharedIntegerWorker.get())) { + java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); + if (queue != null && !queue.isEmpty()) { + com.bencodez.advancedcore.api.user.usercache.UserDataManager.DeferredSharedInteger mutation = queue.peekFirst(); + try { timer.execute(mutation.action()); } + catch (RuntimeException | Error failure) { failures.add(() -> mutation.reject().accept(failure)); } + } + } + if (--sharedIntegerAdmissions == 0) sharedIntegerDrain.complete(null); + } + for (java.lang.Runnable failure : failures) failure.run(); + } + + /** Fail fast rather than close an owner that still has retained integer operations. */ + public void withLegacyIntegerOwnerReplacement(Runnable replacement) { + Objects.requireNonNull(replacement, "replacement"); + if (Boolean.TRUE.equals(legacyIntegerOwnerReplacementThread.get())) { replacement.run(); return; } + synchronized (this) { + if (sharedIntegerOwnerReplacement || sharedIntegerClosing || sharedIntegerAdmissions != 0) + throw new IllegalStateException("Shared integer mutations are active"); + sharedIntegerOwnerReplacement = true; + } + try { + synchronized (legacyRetirementAdmission) { + if (legacyIntegerOwnerReplacement || legacyRetirementActive || legacyRetirementRequested || legacyIntegerShutdown) + throw new IllegalStateException("Legacy owner lifecycle is already active"); + if (activeLegacyIntegerApplications != 0) throw new IllegalStateException("Legacy integer mutations are active"); + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegerCount != 0) throw new IllegalStateException("Legacy integer mutations are still pending"); + } + legacyIntegerOwnerReplacement = true; + } + legacyIntegerOwnerReplacementThread.set(true); + try { replacement.run(); } + finally { + legacyIntegerOwnerReplacementThread.remove(); + synchronized (legacyRetirementAdmission) { legacyIntegerOwnerReplacement = false; } + } + } finally { synchronized (this) { sharedIntegerOwnerReplacement = false; } } + } + + /** Observe the currently admitted logical mutations for this user. */ + public CompletionStage pendingLegacyIntegerMutations(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + synchronized (legacyMutationAdmission) { + java.util.ArrayDeque queue = pendingLegacyIntegers.get(uuid); + if (queue == null) return CompletableFuture.completedFuture(null); + return CompletableFuture.allOf(queue.stream().map(item -> item.completion).toArray(CompletableFuture[]::new)).minimalCompletionStage(); + } + } + + /** Observe shared operations already admitted for this user without cancelling them. */ + public CompletionStage pendingSharedIntegerMutations(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + synchronized (this) { + java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); + if (queue == null || queue.isEmpty()) return CompletableFuture.completedFuture(null).minimalCompletionStage(); + return CompletableFuture.allOf(queue.stream().map(DeferredSharedInteger::receipt).toArray(CompletableFuture[]::new)).minimalCompletionStage(); + } + } + + /** Close new legacy arithmetic admission before the storage owner is shut down. */ + public CompletionStage shutdownLegacyIntegerMutations() { + closeLegacyIntegerAdmissionForShutdown(); + if (UserDataCache.hasLiveLegacyCallback(this) || Boolean.TRUE.equals(legacyIntegerNotification.get())) + return CompletableFuture.failedFuture(new IllegalStateException("Cache retirement cannot await its own direct-write callback")); + CompletableFuture completion = new CompletableFuture<>(); + try { + timer.execute(() -> { + try { + clearCacheAsyncCompletion().whenComplete((ignored, failure) -> { + if (failure == null) completion.complete(null); else completion.completeExceptionally(failure); + }); + } catch (RuntimeException | Error failure) { + reportDeferredStorageFailure(failure); + completion.completeExceptionally(failure); + } + }); + } catch (RejectedExecutionException failure) { + reportDeferredStorageFailure(failure); + completion.completeExceptionally(failure); + } + return completion.minimalCompletionStage(); + } + public void closeLegacyIntegerAdmissionForShutdown() { + synchronized (legacyRetirementAdmission) { legacyIntegerShutdown = true; } + } + + /** A forced owner close cannot safely pass a retained or active arithmetic operation. */ + public boolean hasUnsettledLegacyIntegerMutations() { + synchronized (this) { if (sharedIntegerAdmissions != 0 || sharedIntegerOwnerReplacement) return true; } + synchronized (legacyRetirementAdmission) { + if (activeLegacyIntegerApplications != 0) return true; + synchronized (legacyMutationAdmission) { return pendingLegacyIntegerCount != 0; } + } + } + + private void scheduleLegacyIntegerMutation(PendingLegacyIntegerMutation pending, long delayMillis) { + try { timer.schedule(() -> runLegacyIntegerMutation(pending), delayMillis, TimeUnit.MILLISECONDS); } + catch (RejectedExecutionException failure) { finishLegacyIntegerMutation(pending, null, failure); } + } + private void retryLegacyIntegerMutation(PendingLegacyIntegerMutation pending) { + if (++pending.attempts >= MAX_LEGACY_MUTATION_ATTEMPTS) { + finishLegacyIntegerMutation(pending, null, new IllegalStateException("Legacy integer mutation could not acquire an authoritative cache")); + return; + } + scheduleLegacyIntegerMutation(pending, 25); + } + + private void runLegacyIntegerMutation(PendingLegacyIntegerMutation pending) { + try { + requirePendingLegacyOwner(pending); + UserDataCache.IntegerMutationResult result; + java.util.concurrent.locks.ReentrantLock stripe = legacyMutationStripe(pending.uuid); + if (!stripe.tryLock()) { retryLegacyIntegerMutation(pending); return; } + try { + synchronized (legacyMutationAdmission) { + java.util.ArrayDeque queue = pendingLegacyIntegers.get(pending.uuid); + if (queue == null || queue.peekFirst() != pending) return; + } + requirePendingLegacyOwner(pending); + result = tryMutateLegacyIntegersAdmitted(pending.uuid, pending.values, pending.operation); + } finally { stripe.unlock(); } + if (result == null) { + com.bencodez.advancedcore.api.user.usercache.UserDataManager.LegacyPopulationResult population = populateCompleteLegacyCacheIfSupported(pending.uuid); + if (population == LegacyPopulationResult.UNSUPPORTED) throw new IllegalStateException("Legacy reader does not support strict integer mutation"); + if (population == LegacyPopulationResult.PUBLISHED) { + if (!stripe.tryLock()) { retryLegacyIntegerMutation(pending); return; } + try { + requirePendingLegacyOwner(pending); + result = tryMutateLegacyIntegersAdmitted(pending.uuid, pending.values, pending.operation); + } finally { stripe.unlock(); } + } + } + if (result != null) { finishLegacyIntegerMutation(pending, result, null); return; } + retryLegacyIntegerMutation(pending); + } catch (UserDataCache.LegacyIntegerMutationUncertainException uncertain) { + // Some cache changes may already exist. Keep the original ticket and + // retirement admission unresolved; a blind retry could award twice. + try { reportDeferredStorageFailure(uncertain); } catch (RuntimeException | Error ignored) { } + } catch (RuntimeException | Error failure) { finishLegacyIntegerMutation(pending, null, failure); } + } + private void requirePendingLegacyOwner(PendingLegacyIntegerMutation pending) { + if (!Objects.equals(plugin.getNativeUserStorageOwner(), pending.owner) || !legacyOwnerMatchesProvider(pending.owner)) + throw new IllegalStateException("Legacy integer mutation storage owner changed"); + } + + private void finishLegacyIntegerMutation(PendingLegacyIntegerMutation pending, + UserDataCache.IntegerMutationResult result, Throwable failure) { + if (failure == null) { + // Keep the ticket admitted until its one notification attempt finishes. + // A listener that requests retirement sees the pending ticket and fails + // rather than detaching its cache before notification completes. + legacyIntegerNotification.set(true); + try { notifyLegacyIntegerMutation(result, pending.operation, pending.notification); } + catch (RuntimeException | Error notificationFailure) { + try { reportDeferredStorageFailure(notificationFailure); } catch (RuntimeException | Error ignored) { } + } + finally { legacyIntegerNotification.remove(); } + } + PendingLegacyIntegerMutation next; + synchronized (legacyMutationAdmission) { + java.util.ArrayDeque queue = pendingLegacyIntegers.get(pending.uuid); + if (queue == null || queue.peekFirst() != pending) return; + queue.removeFirst(); pendingLegacyIntegerCount--; + next = queue.peekFirst(); if (next == null) pendingLegacyIntegers.remove(pending.uuid); + } + endLegacyCacheBatch(); + if (failure == null) { + pending.completion.succeed(result); + } else pending.completion.fail(failure); + if (next != null) scheduleLegacyIntegerMutation(next, 0); + } + + /** Fence a direct legacy writer against stale row publication. */ + public LegacyDirectWrite beginLegacyDirectWrite(UUID uuid) { return beginLegacyDirectWrite(uuid, Set.of()); } + + public LegacyDirectWrite beginLegacyDirectWrite(UUID uuid, String key) { return beginLegacyDirectWrite(uuid, key == null ? Set.of() : Set.of(key)); } + + public LegacyDirectWrite beginLegacyDirectWrite(UUID uuid, Set keys) { + return beginLegacyDirectWrite(uuid, keys, keys.stream().anyMatch(key -> key.startsWith("SQLITE:")) ? uuid.toString() : null); + } + public LegacyDirectWrite beginLegacyDirectWrite(UUID uuid, Set keys, String sqliteRow) { + return beginLegacyDirectWrite(uuid, keys, sqliteRow, null); + } + private LegacyDirectWrite beginLegacyDirectWrite(UUID uuid, Set keys, String sqliteRow, LegacyDirectWrite parent) { + Objects.requireNonNull(uuid, "uuid"); + boolean borrowed = parent != null || UserDataCache.hasActiveLegacyBatch(this, uuid); + String identity = sqliteRow == null ? "UUID:" + uuid : "SQLITE:" + sqliteRow; + long sequence; + synchronized (legacyRetirementAdmission) { + if (!borrowed && (legacyRetirementActive || legacyRetirementRequested)) throw new IllegalStateException("Legacy cache retirement is active"); + if (parent != null && parent.ended.get()) throw new IllegalStateException("Legacy direct admission already ended"); + if (legacyDirectAdmissions == 0) { legacyDirectDrain = new CompletableFuture<>(); legacyDirectFailure = null; } + legacyDirectAdmissions++; + legacyDirectAdmissionsByUser.merge(identity, 1, Integer::sum); + sequence = ++legacyDirectSequence; + } + try { if (parent == null) beginLegacyCacheBatch(); } + catch (IllegalStateException transitioning) { + endLegacyDirectAdmission(identity); + return null; + } + boolean admitted = false; + LegacyWriteState state = null; + try { + LegacyRead nested = parent == null && (sqliteRow == null || sqliteRow.equals(uuid.toString())) + ? UserDataCache.disjointNestedLegacyRead(this, uuid, keys) : null; + LegacyRead overlap = parent == null && (sqliteRow == null || sqliteRow.equals(uuid.toString())) + ? UserDataCache.overlappingNestedRead(this, uuid, keys) : null; + boolean[] nestedAdmitted = { false }; + boolean[] overlapAdmitted = { false }; + state = legacyWriteStates.compute(uuid, (id, previous) -> { + LegacyWriteState current = previous == null ? new LegacyWriteState() : previous; + overlapAdmitted[0] = overlap != null && overlap.active && !overlap.writing && overlap.callback == null + && overlap.state == current && overlap.version == current.version && current.writers == 0; + boolean ownNested = nested != null && nested.active && !nested.writing && nested.callback == null + && nested.state == current && nested.version == current.version && current.writers == nested.nestedWriters; + current.writers++; current.version++; + if (overlapAdmitted[0] && overlap.historySource != null && overlap.historyProvider != null + && overlap.historyVersion == overlap.version) overlap.pendingOverlapWriters++; + if (ownNested) { nested.nestedWriters++; nested.version = current.version; nestedAdmitted[0] = true; } + return current; + }); + UserStorage requested = sqliteRow == null ? null : UserStorage.SQLITE; + if (requested == null) for (String key : keys) { + int separator = key.indexOf(':'); + if (separator > 0) try { requested = UserStorage.valueOf(key.substring(0, separator)); } catch (IllegalArgumentException ignored) { } + if (requested != null) break; + } + UserStorage configured = plugin == null ? null : plugin.getStorageType(); + boolean sameStorage = requested == null || configured == null || requested == configured; + UserDataCache cache = sameStorage && (sqliteRow == null || sqliteRow.equals(uuid.toString())) ? userDataCache.get(uuid) : null; + if (cache != null && !cache.matchesLegacyStorage(requested)) cache = null; + long cacheVersion = cache == null ? -1 : cache.beginLegacyDirectWrite(); + if (cacheVersion < 0) cache = null; + admitted = true; + LegacyDirectWrite result = new LegacyDirectWrite(uuid, sqliteRow, cache, state, keys, sequence, cacheVersion); + result.borrowedBatch = parent != null; + if (nestedAdmitted[0]) result.nestedRead = nested; + if (overlapAdmitted[0]) { + result.overlapRead = overlap; + if (overlap.pendingOverlapWriters > 0 && overlap.historyProvider instanceof com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL mysql) + result.capturedNestedMysql = mysql; + if (overlap.pendingOverlapWriters > 0 && overlap.historyMode == 0 + && overlap.historySource instanceof com.bencodez.advancedcore.api.user.userstorage.sql.UserTable sqlite) + result.capturedNestedSqlite = sqlite; + } + return result; + } finally { + if (!admitted) { + try { if (state != null) finishLegacyDirectState(uuid, state); } + finally { + try { if (parent == null) endLegacyCacheBatch(); } + finally { endLegacyDirectAdmission(identity); } + } + } + } + } + + boolean isLiveLegacyDirectWrite(LegacyDirectWrite admission) { + return admission != null && !admission.ended.get(); + } + + public void endLegacyDirectWrite(LegacyDirectWrite admission) { + if (admission == null) return; + if (admission.redirected != null) endLegacyDirectWrite(admission.redirected); + for (LegacyDirectWrite guard : admission.destinationGuards.values()) endLegacyDirectWrite(guard); + if (!admission.ended.compareAndSet(false, true)) throw new IllegalStateException("Legacy direct write already ended"); + if (admission.successful.get() && !admission.failureRecorded.get()) synchronized (legacyRetirementAdmission) { + java.util.Map failed = legacyUnrecoveredWrites.get(admission.identity()); + for (String key : admission.keys) { + legacyDirectSuccesses.computeIfAbsent(admission.identity(), ignored -> new java.util.HashMap<>()).merge(key, admission.sequence, Math::max); + if (failed != null && failed.getOrDefault(key, Long.MAX_VALUE) < admission.sequence) failed.remove(key); + } + if (failed != null && failed.isEmpty()) legacyUnrecoveredWrites.remove(admission.identity()); + } + try { if (admission.cache != null) admission.cache.endLegacyDirectWrite(); } + finally { + try { finishLegacyDirectState(admission.uuid, admission.state, admission); } + finally { + try { if (!admission.borrowedBatch) endLegacyCacheBatch(); } + finally { endLegacyDirectAdmission(admission.identity()); } + } + } + } + /** Admit a possible SQLite primary destination before the physical write. */ + public void guardLegacySqliteDestination(LegacyDirectWrite admission, com.bencodez.simpleapi.sql.data.DataValue value) { + if (admission == null || value == null || !value.isString() || admission.sqliteRow == null) return; + String row = value.getString(); + if (row == null) return; + admission.possibleDestinations.add(row); + if (row.equals(admission.sqliteRow) || admission.destinationGuards.containsKey(row)) return; + try { + UUID target = UUID.fromString(row); + admission.destinationGuards.put(row, beginLegacyDirectWrite(target, Set.of(UserDataCache.sqliteDirectKey("uuid")), row, admission)); + } catch (IllegalArgumentException invalidUuid) { /* Preserve non-UUID storage identities. */ } + } + + /** Check a direct write against later completed writes without waiting for storage. */ + LegacyDirectWrite bindLegacyDirectRow(LegacyDirectWrite original, String row, boolean sqlite) { + if (original == null || isLegacyDirectIdentity(original, row)) return original; + if (original.redirected != null) { + if (isLegacyDirectIdentity(original.redirected, row)) return original.redirected; + throw new IllegalStateException("Legacy write identity changed during persistence"); + } + UUID actual = UUID.fromString(row); + LegacyDirectWrite redirected = beginLegacyDirectWrite(actual, original.keys, sqlite ? row : null, original); + if (original.cache != null) original.cache.restoreRedirectedPublication(original.priorPublication, original.cacheVersion); + redirected.destinationGuards.putAll(original.destinationGuards); + original.destinationGuards.clear(); + original.redirected = redirected; + for (String destination : original.possibleDestinations) + guardLegacySqliteDestination(redirected, new com.bencodez.simpleapi.sql.data.DataValueString(destination)); + return redirected; + } + private LegacyDirectWrite effectiveLegacyDirectWrite(LegacyDirectWrite admission) { + while (admission != null && admission.redirected != null) admission = admission.redirected; + return admission; + } + boolean isLegacyDirectIdentity(LegacyDirectWrite admission, String row) { return admission != null && (admission.sqliteRow != null ? admission.sqliteRow.equals(row) : admission.uuid.equals(UUID.fromString(row))); } + boolean isRelatedLegacyBatchFailure(LegacyDirectWrite admission, UUID uuid, UserStorage storage, Object values) { + if (admission == null || uuid == null || storage == null || !(values instanceof Map map) + || !admission.uuid.equals(uuid)) return false; + if (storage == UserStorage.SQLITE ? !uuid.toString().equals(admission.sqliteRow) : admission.sqliteRow != null) return false; + for (Object key : map.keySet()) if (key instanceof String name) { + String identity = storage == UserStorage.SQLITE ? UserDataCache.sqliteDirectKey(name) : storage.name() + ":" + name; + if (admission.keys.contains(identity)) return true; + } + return false; + } + public void configureSqliteBulkPrimary(LegacyDirectWrite admission, boolean insertChanges, boolean updateChanges) { + if (admission != null) { admission.sqliteInsertChangesPrimary = insertChanges; admission.sqliteUpdateChangesPrimary = updateChanges; } + } + void recordSqliteDirectStatement(LegacyDirectWrite admission, boolean insertStatement) { + if (admission != null) { + admission.sqliteInsertStatement = insertStatement; + Boolean changes = insertStatement ? admission.sqliteInsertChangesPrimary : admission.sqliteUpdateChangesPrimary; + if (changes != null) synchronized (legacyRetirementAdmission) { + String key = UserDataCache.sqliteDirectKey("uuid"); + if (changes) admission.keys.add(key); else admission.keys.remove(key); + } + } + } + boolean hasLegacyDirectKey(LegacyDirectWrite admission, String key) { return admission != null && admission.keys.contains(key); } + + boolean isCurrentLegacyDirectKey(LegacyDirectWrite admission, String row, String storageKey) { + if (admission == null || !isLegacyDirectIdentity(admission, row) + || (!admission.keys.contains(storageKey) && !storageKey.equals(UserDataCache.sqliteDirectKey("uuid")))) return true; + synchronized (legacyRetirementAdmission) { + return legacyDirectSuccesses.getOrDefault(admission.identity(), java.util.Map.of()).getOrDefault(storageKey, 0L) <= admission.sequence; + } + } + boolean isCurrentMysqlDirectKey(LegacyDirectWrite admission, String row, String key, boolean foldColumns) { + String identity = UserStorage.MYSQL.name() + ":" + key; + if (!foldColumns) return isCurrentLegacyDirectKey(admission, row, identity); + if (admission == null || !isLegacyDirectIdentity(admission, row) || !admission.keys.contains(identity)) return true; + synchronized (legacyRetirementAdmission) { + return legacyDirectSuccesses.getOrDefault(admission.identity(), java.util.Map.of()) + .getOrDefault(mysqlFoldedKey(identity), 0L) <= admission.sequence; + } + } + private static String mysqlFoldedKey(String identity) { + StringBuilder folded = new StringBuilder("MYSQL_FOLDED:"); + for (int i = 0; i < identity.length(); i++) folded.append(Character.toLowerCase(Character.toUpperCase(identity.charAt(i)))); + return folded.toString(); + } + + public void markLegacyDirectWriteSucceeded(LegacyDirectWrite admission) { + admission = effectiveLegacyDirectWrite(admission); + if (admission != null && !admission.ended.get()) admission.successful.set(true); + } + void recordLegacyDirectCachePublication(LegacyDirectWrite admission, UserDataCache cache, long version) { + if (admission != null && !admission.ended.get() && admission.cache == cache) admission.cacheVersion = version; + } + void rememberLegacyDirectCachePublication(LegacyDirectWrite admission, UserDataCache cache, UserDataCache.DirectCachePublication prior) { + if (admission != null && !admission.ended.get() && admission.cache == cache) admission.priorPublication = prior; + } + boolean matchesLegacyDirectCacheRow(LegacyDirectWrite admission, UserDataCache cache) { + return admission == null || admission.sqliteRow == null || (cache.getUuid() != null && admission.sqliteRow.equals(cache.getUuid().toString())); + } + long legacyDirectSequence(LegacyDirectWrite admission) { return admission.sequence; } + + /** Publish guarded keys while SQLite still holds its committing writer lock. */ + void recordSqliteDirectCommit(LegacyDirectWrite admission, Set keys, Object provider) { + if (admission == null || admission.ended.get()) return; + synchronized (legacyRetirementAdmission) { + if (provider != null) admission.confirmedProvider = provider; + java.util.Map successes = legacyDirectSuccesses.computeIfAbsent(admission.identity(), ignored -> new java.util.HashMap<>()); + for (String key : keys) if (admission.keys.contains(key)) { + admission.confirmedKeys.add(key); + long previous = successes.getOrDefault(key, 0L); + admission.sqliteCommitPrevious.putIfAbsent(key, previous); + successes.merge(key, admission.sequence, Math::max); + } + } + } + void recordSqliteDestinationCommit(LegacyDirectWrite admission, String row) { + if (admission == null || row == null || !admission.keys.contains(UserDataCache.sqliteDirectKey("uuid"))) return; + LegacyDirectWrite guard = admission.destinationGuards.get(row); + if (guard != null) recordSqliteDirectCommit(guard, Set.of(UserDataCache.sqliteDirectKey("uuid")), null); + } + /** Publish completed native-provider keys before releasing its writer lock. */ + void recordLegacyProviderCommit(LegacyDirectWrite admission, Set keys, boolean foldColumns, Object provider) { + if (admission == null || admission.ended.get() || admission.failureRecorded.get()) return; + synchronized (legacyRetirementAdmission) { + if (provider != null) { admission.confirmedProvider = provider; admission.confirmedMysqlFold = foldColumns; } + java.util.Map successes = legacyDirectSuccesses.computeIfAbsent(admission.identity(), ignored -> new java.util.HashMap<>()); + java.util.Map failed = legacyUnrecoveredWrites.get(admission.identity()); + for (String key : keys) if (admission.keys.contains(key)) { + admission.confirmedKeys.add(key); + successes.merge(key, admission.sequence, Math::max); + if (foldColumns) successes.merge(mysqlFoldedKey(key), admission.sequence, Math::max); + if (foldColumns && failed != null && failed.size() <= 2048) + failed.entrySet().removeIf(entry -> entry.getKey().equalsIgnoreCase(key) && entry.getValue() < admission.sequence); + } + if (failed != null && failed.isEmpty()) legacyUnrecoveredWrites.remove(admission.identity()); + } + } + + /** Undo a commit-hook observation when SQLite reports a rollback. */ + void rollbackSqliteDirectCommit(LegacyDirectWrite admission) { + if (admission == null) return; + for (LegacyDirectWrite guard : admission.destinationGuards.values()) rollbackSqliteDirectCommit(guard); + synchronized (legacyRetirementAdmission) { + java.util.Map successes = legacyDirectSuccesses.get(admission.identity()); + if (successes != null) for (java.util.Map.Entry entry : admission.sqliteCommitPrevious.entrySet()) { + if (successes.getOrDefault(entry.getKey(), 0L) == admission.sequence) { + if (entry.getValue() == 0) successes.remove(entry.getKey()); + else successes.put(entry.getKey(), entry.getValue()); + } + } + admission.sqliteCommitPrevious.clear(); + admission.confirmedKeys.clear(); + admission.confirmedProvider = null; + } + } + + /** Fence arithmetic on the previous row while an external bulk write is admitted. */ + public void invalidateLegacyBulkSnapshot(LegacyDirectWrite admission) { + if (admission != null && admission.cache != null && !admission.ended.get()) + admission.cache.invalidateLegacyBulkSnapshot(); + } + + /** Reconcile only the keys not superseded by a completed newer direct write. */ + public void completeLegacyBulkValues(LegacyDirectWrite admission, java.util.Map values, + java.util.function.Function identity) { + admission = effectiveLegacyDirectWrite(admission); + if (admission == null || admission.ended.get() || admission.failureRecorded.get() + || (admission.cache == null && admission.destinationGuards.isEmpty())) return; + java.util.Map accepted; + synchronized (legacyRetirementAdmission) { + java.util.Map successes = legacyDirectSuccesses.computeIfAbsent(admission.identity(), ignored -> new java.util.HashMap<>()); + java.util.LinkedHashMap> selected = new java.util.LinkedHashMap>(); + boolean mysqlFold = plugin != null && plugin.getMysql() != null && Boolean.TRUE.equals(plugin.getMysql().caseInsensitiveColumnNames()); + for (java.util.Map.Entry entry : values.entrySet()) { + if (entry.getKey() == null) continue; + String key = identity.apply(entry.getKey()); + if (admission.cache == null && !key.equals(UserDataCache.sqliteDirectKey("uuid"))) continue; + long latest = successes.getOrDefault(key, 0L); + if (key.startsWith("MYSQL:") && mysqlFold) + latest = Math.max(latest, successes.getOrDefault(mysqlFoldedKey(key), 0L)); + if (admission.keys.contains(key) && latest <= admission.sequence) { + String selectedKey = key.startsWith("MYSQL:") && mysqlFold ? mysqlFoldedKey(key) : key; + java.util.Map.Entry effective = entry; + if (Boolean.TRUE.equals(admission.sqliteInsertStatement) && admission.sqliteInsertChangesPrimary == null + && key.equals(UserDataCache.sqliteDirectKey("uuid")) && admission.sqliteRow != null) + effective = new java.util.AbstractMap.SimpleImmutableEntry<>(entry.getKey(), new com.bencodez.simpleapi.sql.data.DataValueString(admission.sqliteRow)); + if (Boolean.TRUE.equals(admission.sqliteInsertStatement)) selected.putIfAbsent(selectedKey, effective); + else selected.put(selectedKey, effective); + successes.merge(key, admission.sequence, Math::max); + } + } + accepted = new java.util.LinkedHashMap<>(); + for (java.util.Map.Entry entry : selected.values()) accepted.put(entry.getKey(), entry.getValue()); + if (admission.cache != null) admission.cache.completeLegacyBulkValues(accepted, admission.cacheVersion, identity); + } + for (java.util.Map.Entry entry : accepted.entrySet()) if (identity.apply(entry.getKey()).equals(UserDataCache.sqliteDirectKey("uuid")) + && entry.getValue() != null && entry.getValue().isString()) { + LegacyDirectWrite guard = admission.destinationGuards.get(entry.getValue().getString()); + if (guard != null) { + UserDataCache target = guard.sqliteRow.equals(guard.uuid.toString()) ? userDataCache.get(guard.uuid) : null; + if (target != null) target.fenceIncomingSqliteMove(guard.cacheVersion); + } + } + } + + public void failLegacyDirectWrite(LegacyDirectWrite admission, + com.bencodez.advancedcore.api.user.usercache.change.UserDataChange change, Throwable failure) { + admission = effectiveLegacyDirectWrite(admission); + if (admission == null || admission.ended.get() || !admission.failureRecorded.compareAndSet(false, true)) return; + boolean retained = false; + // Bulk persistence intentionally excludes the exact primary-key name, so + // queuing it would falsely acknowledge a failed scalar write as retried. + boolean primaryAlias = change != null && change.getKey() != null && admission.sqliteRow != null + && UserDataCache.sqliteDirectKey(change.getKey()).equals(UserDataCache.sqliteDirectKey("uuid")); + if (!(failure instanceof com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.StaleRowWriteException) + && admission.cache != null && (change == null || (!"uuid".equals(change.getKey()) && !primaryAlias))) { + try { admission.cache.retainFailedLegacyDirectChange(change, admission.cacheVersion, + change != null && change.getKey() != null && admission.keys.contains(UserDataCache.sqliteDirectKey(change.getKey()))); retained = true; } + catch (RuntimeException | Error retryFailure) { failure.addSuppressed(retryFailure); } + } + synchronized (legacyRetirementAdmission) { + if (legacyDirectFailure == null) legacyDirectFailure = failure; + if (!retained) for (String key : admission.keys) { + java.util.Map successes = legacyDirectSuccesses.getOrDefault(admission.identity(), java.util.Map.of()); + long latest = successes.getOrDefault(key, 0L); + if (key.startsWith("MYSQL:") && plugin != null && plugin.getMysql() != null && Boolean.TRUE.equals(plugin.getMysql().caseInsensitiveColumnNames())) + latest = Math.max(latest, successes.getOrDefault(mysqlFoldedKey(key), 0L)); + if (latest < admission.sequence) + legacyUnrecoveredWrites.computeIfAbsent(admission.identity(), ignored -> new java.util.HashMap<>()).merge(key, admission.sequence, Math::max); + } + } + } + + private void endLegacyDirectAdmission(String identity) { + CompletableFuture drained = null; + Throwable failure = null; + synchronized (legacyRetirementAdmission) { + int remaining = legacyDirectAdmissionsByUser.getOrDefault(identity, 0) - 1; + if (remaining < 0) throw new IllegalStateException("Legacy direct user admission is unbalanced"); + if (remaining == 0) { legacyDirectAdmissionsByUser.remove(identity); legacyDirectSuccesses.remove(identity); } + else legacyDirectAdmissionsByUser.put(identity, remaining); + if (--legacyDirectAdmissions == 0) { drained = legacyDirectDrain; failure = legacyDirectFailure; legacyDirectSuccesses.clear(); } + } + if (drained != null) { + if (failure == null) drained.complete(null); else drained.completeExceptionally(failure); + } + } + + + private void finishLegacyDirectState(UUID uuid, LegacyWriteState state) { + finishLegacyDirectState(uuid, state, null); + } + private void finishLegacyDirectState(UUID uuid, LegacyWriteState state, LegacyDirectWrite admission) { + legacyWriteStates.compute(uuid, (id, current) -> { + if (current != state || current.writers <= 0) throw new IllegalStateException("Legacy write state changed"); + LegacyRead nested = admission == null ? null : admission.nestedRead; + LegacyRead overlap = admission == null ? null : admission.overlapRead; + boolean confirmedOverlap = overlap != null && overlap.active && admission.successful.get() + && !admission.failureRecorded.get() && admission.redirected == null && !admission.confirmedKeys.isEmpty() + && current.writers == 1 && current.version == overlap.version + 1; + boolean ownDisjointCompletion = nested != null && nested.active && admission.successful.get() + && !admission.failureRecorded.get() && admission.redirected == null && admission.destinationGuards.isEmpty() + && current.version == nested.version && current.writers == nested.nestedWriters; + if (nested != null) nested.nestedWriters--; + if (overlap != null && (admission.capturedNestedMysql != null || admission.capturedNestedSqlite != null)) overlap.pendingOverlapWriters--; + current.writers--; current.version++; + boolean sqliteHistory = admission != null && admission.sqliteRow != null && admission.sqliteRow.equals(uuid.toString()) + && !admission.keys.contains(UserDataCache.sqliteDirectKey("uuid")); + boolean mysqlHistory = admission != null && admission.sqliteRow == null && !admission.keys.isEmpty() + && admission.keys.stream().allMatch(key -> key.startsWith("MYSQL:") && !key.substring(6).equalsIgnoreCase("uuid") && key.chars().allMatch(c -> c < 128)); + if (admission != null && admission.successful.get() && !admission.failureRecorded.get() + && admission.confirmedProvider != null && admission.redirected == null && admission.destinationGuards.isEmpty() + && (sqliteHistory || mysqlHistory) && !admission.keys.isEmpty() && admission.confirmedKeys.containsAll(admission.keys)) { + int mode = sqliteHistory ? 0 : admission.confirmedMysqlFold ? 2 : 1; + current.confirmedTransitions.computeIfAbsent(admission.confirmedProvider, ignored -> new long[3])[mode] += 2; + java.util.Map versions = current.confirmedKeyVersions.computeIfAbsent(admission.confirmedProvider, ignored -> new java.util.HashMap<>()); + for (String key : admission.keys) versions.put(mode == 2 ? "MYSQLF:" + key.substring(6).toLowerCase(java.util.Locale.ROOT) : key, current.version); + } + if (confirmedOverlap) { + overlap.confirmedOverlap.addAll(admission.confirmedKeys); + overlap.confirmedOverlapVersion = current.version; + } + if (ownDisjointCompletion) nested.version = current.version; + return current.writers == 0 && current.readers == 0 ? null : current; + }); + } + void beginConfirmedBatchHistory(LegacyRead read, Object provider, int mode, Object source) { + if (read == null || provider == null || source == null) return; + legacyWriteStates.computeIfPresent(read.uuid, (id, current) -> { + if (read.active && !read.writing && current == read.state && current.writers == 0 && current.version == read.version) { + read.historyProvider = provider; + read.historySource = source; + read.historyVersion = current.version; + read.historyMode = mode; + read.historyTransitions = current.confirmedTransitions.getOrDefault(provider, new long[3])[mode]; + } + return current; + }); + } + Set claimConfirmedNestedKeys(LegacyRead read, Object provider) { + if (read == null) return Set.of(); + AtomicReference> result = new AtomicReference<>(Set.of()); + legacyWriteStates.computeIfPresent(read.uuid, (id, current) -> { + if (provider != null && provider == read.historyProvider && read.historyVersion >= 0 + && read.active && current == read.state && read.pendingOverlapWriters > 0 + && current.writers == read.pendingOverlapWriters && current.version == read.historyVersion + read.pendingOverlapWriters + && ((read.historyMode == 0 && read.historySource instanceof com.bencodez.advancedcore.api.user.userstorage.sql.UserTable sqlite + && sqlite.legacyDirectProviderIdentity() == provider) + || (read.historyMode != 0 && provider instanceof com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL mysql + && java.util.Objects.equals(mysql.caseInsensitiveColumnNames(), read.historyMode == 2)))) { + read.version = current.version; + read.pendingOverlapAuthorized = true; + return current; + } + if (provider != null && provider == read.historyProvider && read.historyVersion >= 0 && read.active + && current == read.state && current.writers == 0 && current.version > read.historyVersion + && current.version - read.historyVersion == current.confirmedTransitions.getOrDefault(provider, new long[3])[read.historyMode] - read.historyTransitions) { + java.util.HashSet confirmed = new java.util.HashSet(); + current.confirmedKeyVersions.getOrDefault(provider, Map.of()).forEach((key, version) -> { if (version > read.historyVersion) confirmed.add(key); }); + read.version = current.version; + read.filteredOverlap.addAll(confirmed); + if (current.version > read.lastConfirmedClaimVersion) { + read.lastConfirmedClaimVersion = current.version; + read.confirmedClaimSerial++; + } + result.set(Set.copyOf(confirmed)); + return current; + } + if (read.active && current == read.state && current.writers == 0 && current.version == read.confirmedOverlapVersion + && !read.confirmedOverlap.isEmpty()) { + read.version = current.version; + read.filteredOverlap.addAll(read.confirmedOverlap); + if (current.version > read.lastConfirmedClaimVersion) { + read.lastConfirmedClaimVersion = current.version; + read.confirmedClaimSerial++; + } + result.set(Set.copyOf(read.confirmedOverlap)); + } + return current; + }); + return result.get(); + } + long confirmedNestedClaimSerial(LegacyRead read) { + return read == null ? 0 : read.confirmedClaimSerial; + } + boolean capturedMysqlFoldsColumnNames(LegacyRead read) { + return read != null && read.historyProvider != null && read.historyVersion >= 0 && read.historyMode == 2; + } + boolean wasConfirmedNestedKeyFiltered(LegacyRead read, String key, UserStorage storage) { + return read != null && key != null && storage != null && (read.filteredOverlap.contains(storage == UserStorage.SQLITE + ? UserDataCache.sqliteDirectKey(key) : storage.name() + ":" + key) + || (storage == UserStorage.MYSQL && key.chars().allMatch(c -> c < 128) && read.filteredOverlap.contains("MYSQLF:" + key.toLowerCase(java.util.Locale.ROOT)))); + } + com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL capturedNestedMysql(LegacyDirectWrite admission) { + return admission == null ? null : admission.capturedNestedMysql; + } + com.bencodez.advancedcore.api.user.userstorage.sql.UserTable capturedNestedSqlite(LegacyDirectWrite admission) { + return admission == null ? null : admission.capturedNestedSqlite; + } + + + /** Load a strict legacy row on a worker; false means its generation changed. */ + public boolean populateCompleteLegacyCache(UUID uuid) { + return populateCompleteLegacyCache(uuid, null, false) == LegacyPopulationResult.PUBLISHED; + } + private boolean populateCompleteLegacyCache(UUID uuid, UserDataCache expectedCache) { + return populateCompleteLegacyCache(uuid, expectedCache, false) == LegacyPopulationResult.PUBLISHED; + } + /** Distinguishes a custom reader without strict authority from a transient generation change. */ + public enum LegacyPopulationResult { PUBLISHED, RETRY, UNSUPPORTED } + /** Load strict legacy data on a worker, preserving unsupported custom-reader behavior. */ + public LegacyPopulationResult populateCompleteLegacyCacheIfSupported(UUID uuid) { + return populateCompleteLegacyCache(uuid, null, true); + } + private LegacyPopulationResult populateCompleteLegacyCache(UUID uuid, UserDataCache expectedCache, boolean checkCapability) { + Objects.requireNonNull(uuid, "uuid"); + if (hasSharedSqlBackend() || isPlatformOwnedThread()) throw new IllegalStateException("Legacy row loading requires a storage worker"); + beginLegacyCacheBatch(); + try { + LegacyWriteState state = legacyWriteStates.compute(uuid, (id, previous) -> { + LegacyWriteState current = previous == null ? new LegacyWriteState() : previous; + current.readers++; return current; + }); + long[] expected = new long[1]; boolean[] writing = new boolean[1]; + legacyWriteStates.computeIfPresent(uuid, (id, current) -> { expected[0] = current.version; writing[0] = current.writers != 0; return current; }); + try { + if (writing[0]) return LegacyPopulationResult.RETRY; + UserStorage storage = plugin.getStorageType(); + AdvancedCorePlugin.UserStorageOwner owner = plugin.getNativeUserStorageOwner(); + Object provider = owner == null ? null : storage == UserStorage.MYSQL ? owner.mysql() + : storage == UserStorage.SQLITE ? owner.table() : null; + if (!legacyOwnerMatchesProvider(owner) || (storage != null && owner != null && owner.storageType() != storage)) + return LegacyPopulationResult.RETRY; + com.bencodez.advancedcore.api.user.UserData capabilityData = checkCapability ? plugin.getUserManager().getUser(uuid, false).getUserData() : null; + if (checkCapability && !com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage.withStrictOwner(plugin, owner, + capabilityData::supportsStrictCompleteValues)) { + if (plugin.getStorageType() != storage || !Objects.equals(plugin.getNativeUserStorageOwner(), owner) + || (owner != null && storage == UserStorage.MYSQL && plugin.getMysql() != provider) + || (owner != null && storage == UserStorage.SQLITE && plugin.getSQLiteUserTable() != provider) + || !legacyOwnerMatchesProvider(owner)) return LegacyPopulationResult.RETRY; + return LegacyPopulationResult.UNSUPPORTED; + } + UserDataCache cache = expectedCache == null ? ensureSharedCachePlaceholder(uuid) : userDataCache.get(uuid); + if (expectedCache != null && cache != expectedCache) return LegacyPopulationResult.RETRY; + if (!cache.matchesLegacyStorage(storage) || !cache.matchesLegacyOwner(owner) || !legacyOwnerMatchesProvider(owner) + || (storage != null && owner != null && owner.storageType() != storage)) return LegacyPopulationResult.RETRY; + long version = cache.getSharedSnapshotVersion(); + com.bencodez.advancedcore.api.user.UserData data = checkCapability ? capabilityData : plugin.getUserManager().getUser(uuid, false).getUserData(); + java.util.HashMap row = com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage.withStrictOwner(plugin, owner, + data::getValuesIfStrictComplete); + if (row == null) throw new IllegalStateException("Complete legacy user row is unavailable"); + boolean sqlite = storage == UserStorage.SQLITE; + com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL mysqlProvider = storage == UserStorage.MYSQL ? (owner == null ? plugin.getMysql() : owner.mysql()) : null; + boolean mysqlFold = storage == UserStorage.MYSQL && mysqlProvider != null + && Boolean.TRUE.equals(mysqlProvider.caseInsensitiveColumnNames()); + java.util.HashSet sqliteColumns = new java.util.HashSet(); + java.util.TreeSet mysqlColumns = new java.util.TreeSet(String.CASE_INSENSITIVE_ORDER); + if (sqlite) for (String name : row.keySet()) if (name != null) sqliteColumns.add(UserDataCache.sqliteDirectKey(name)); + if (mysqlFold) for (String name : row.keySet()) if (name != null) mysqlColumns.add(name); + for (UserDataKey key : getKeys()) { + if ((!sqlite || sqliteColumns.add(UserDataCache.sqliteDirectKey(key.getKey()))) && (!mysqlFold || mysqlColumns.add(key.getKey()))) row.putIfAbsent(key.getKey(), key.getDefault()); + } + boolean[] published = new boolean[1]; + legacyWriteStates.computeIfPresent(uuid, (id, current) -> { + if (current == state && current.writers == 0 && current.version == expected[0] && userDataCache.get(uuid) == cache + && plugin.getStorageType() == storage && Objects.equals(plugin.getNativeUserStorageOwner(), owner) + && (owner == null || storage != UserStorage.MYSQL || plugin.getMysql() == provider) + && (owner == null || storage != UserStorage.SQLITE || plugin.getSQLiteUserTable() == provider) + && legacyOwnerMatchesProvider(owner)) + published[0] = cache.tryPublishCompleteLegacySnapshot(row, version); + return current; + }); + return published[0] ? LegacyPopulationResult.PUBLISHED : LegacyPopulationResult.RETRY; + } finally { + legacyWriteStates.compute(uuid, (id, current) -> { + if (current != state || current.readers <= 0) throw new IllegalStateException("Legacy read state changed"); + current.readers--; + return current.writers == 0 && current.readers == 0 ? null : current; + }); + } + } finally { endLegacyCacheBatch(); } + } + private boolean legacyOwnerMatchesProvider(AdvancedCorePlugin.UserStorageOwner owner) { + if (owner == null) return true; + if (owner.storageType() == UserStorage.SQLITE) return plugin.getSQLiteUserTable() == owner.table(); + if (owner.storageType() == UserStorage.MYSQL) return plugin.getMysql() == owner.mysql(); + return false; + } public boolean containsKey(UUID uuid) { return userDataCache.containsKey(uuid); } public UserDataCache getCache(UUID uuid) { if (hasSharedSqlBackend() && isPlatformOwnedThread()) { @@ -1433,6 +2858,11 @@ private void removeCacheExclusively(UUID uuid, boolean shared) { // Eviction must only retire an already-published cache. Loading a missing // user from storage just to remove it adds I/O and extends exclusive admission. UserDataCache cache = userDataCache.get(uuid); + synchronized (legacyMutationAdmission) { + if (pendingLegacyIntegers.containsKey(uuid)) throw new IllegalStateException("Legacy integer mutation is pending"); + legacyIntegerCacheRetirements.add(uuid); + } + try { if (cache != null) { cache.beginRemoval(); try { @@ -1448,6 +2878,7 @@ private void removeCacheExclusively(UUID uuid, boolean shared) { boolean removed = retireSharedCache(uuid, cache); Consumer listener = sharedCacheRemovalListener; if (removed && listener != null) listener.accept(uuid); + } finally { synchronized (legacyMutationAdmission) { legacyIntegerCacheRetirements.remove(uuid); } } } public void updateCacheOnline() { 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 9e7a880b7c..3c4aefa0a6 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 @@ -33,6 +33,15 @@ import lombok.Getter; public class MySQL extends AbstractSqlTable { + /** Subclasses must explicitly opt in before replay transactions bypass their overrides. */ + public boolean supportsAtomicReplayTransactions() { return getClass() == MySQL.class; } + /** Native identifier rules; null leaves unknown dialects unresolved. */ + public Boolean caseInsensitiveColumnNames() { + if ((dbType == DbType.MYSQL || dbType == DbType.MARIADB)) return Boolean.TRUE; + if (dbType == DbType.POSTGRESQL) return Boolean.FALSE; + return null; + } + private static final ThreadLocal STRICT_ROW_READ = new ThreadLocal<>(); private final AdvancedCorePlugin plugin; @@ -698,14 +707,17 @@ public ArrayList getExact(String uuid) { } /** Read for shared storage: never publish a schema-only snapshot after SQL failure. */ - public ArrayList getExactStrict(String uuid) { - return getExactQuery(new Column("uuid", new DataValueString(uuid)), true); - } - - public ArrayList getExactQuery(Column column) { - return getExactQuery(column, false); - } - + public ArrayList getExactStrict(String uuid) { + Boolean previous = STRICT_ROW_READ.get(); + STRICT_ROW_READ.set(Boolean.TRUE); + try { return getExact(uuid); } + finally { if (previous == null) STRICT_ROW_READ.remove(); else STRICT_ROW_READ.set(previous); } + } + + public ArrayList getExactQuery(Column column) { + return getExactQuery(column, Boolean.TRUE.equals(STRICT_ROW_READ.get())); + } + private ArrayList getExactQuery(Column column, boolean strict) { ArrayList result = new ArrayList<>(); @@ -808,15 +820,26 @@ public void insertQuery(String index, List cols) { plugin.devDebug("DB QUERY: " + query); try { - Query prepared = new Query(mysql, query); - prepared.setParameter(1, UUID.fromString(index)); - for (int i = 0; i < cols.size(); i++) { - prepared.setParameter(i + 2, toSqlValue(cols.get(i).getValue())); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyBatchPersistence(index) + || com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) { + try (Connection connection = mysql.getConnectionManager().getConnectionChecked(); + PreparedStatement statement = connection.prepareStatement(query)) { + statement.setObject(1, UUID.fromString(index)); + for (int i = 0; i < cols.size(); i++) statement.setObject(i + 2, toSqlValue(cols.get(i).getValue())); + statement.executeUpdate(); + } + } else { + Query prepared = new Query(mysql, query); + prepared.setParameter(1, UUID.fromString(index)); + for (int i = 0; i < cols.size(); i++) prepared.setParameter(i + 2, toSqlValue(cols.get(i).getValue())); + prepared.executeUpdate(); } - prepared.executeUpdate(); } catch (Exception e) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.propagateLegacyBatchWriteFailure(index, e); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(index, e); debug(e); plugin.debug("Failed to insert/upsert player " + index); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) return; } } else { StringBuilder query = new StringBuilder("INSERT IGNORE INTO ").append(qi(tableName)).append(" (") @@ -833,15 +856,30 @@ public void insertQuery(String index, List cols) { plugin.devDebug("DB QUERY: " + query.toString()); try { - Query prepared = new Query(mysql, query.toString()); - prepared.setParameter(1, index); - for (int i = 0; i < cols.size(); i++) { - prepared.setParameter(i + 2, toSqlValue(cols.get(i).getValue())); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyBatchPersistence(index) + || com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) { + try (Connection connection = mysql.getConnectionManager().getConnectionChecked(); + PreparedStatement statement = connection.prepareStatement(query.toString())) { + statement.setObject(1, index); + for (int i = 0; i < cols.size(); i++) statement.setObject(i + 2, toSqlValue(cols.get(i).getValue())); + if (statement.executeUpdate() == 0) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectLegacyBatchInsertConflict(index); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) + throw new SQLException("Direct insert did not write a row"); + } + } + } else { + Query prepared = new Query(mysql, query.toString()); + prepared.setParameter(1, index); + for (int i = 0; i < cols.size(); i++) prepared.setParameter(i + 2, toSqlValue(cols.get(i).getValue())); + prepared.executeUpdate(); } - prepared.executeUpdate(); } catch (Exception e) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.propagateLegacyBatchWriteFailure(index, e); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(index, e); debug(e); plugin.debug("Failed to insert player " + index); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) return; } } @@ -871,6 +909,18 @@ public void update(String index, List cols, boolean runAsync) { } synchronized (updateLock) { + cols = com.bencodez.advancedcore.api.user.usercache.UserDataCache.filterConfirmedLegacyBatchColumns(index, cols, this); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectStaleLegacyBatch(index); + if (cols.isEmpty()) return; + boolean direct = !runAsync && com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index); + boolean superseded = false; + if (direct) { + ArrayList current = new ArrayList(); + for (Column col : cols) if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.isCurrentLegacyProviderKey(index, col.getName(), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB))) current.add(col); + superseded = current.size() != cols.size(); + if (current.isEmpty()) return; + cols = current; + } if (getUuids().contains(index) || containsKeyQuery(index)) { StringBuilder sb = new StringBuilder(); sb.append("UPDATE ").append(qi(tableName)).append(" SET "); @@ -888,21 +938,36 @@ public void update(String index, List cols, boolean runAsync) { plugin.devDebug("DB QUERY: " + query); try { - Query q = new Query(mysql, query); - for (int i = 0; i < cols.size(); i++) { - q.setParameter(i + 1, toSqlValue(cols.get(i).getValue())); - } - q.setParameter(cols.size() + 1, toUuidValue(index)); - if (runAsync) { - q.executeUpdateAsync(); + if ((com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyBatchPersistence(index) + || com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) && !runAsync) { + try (Connection connection = mysql.getConnectionManager().getConnectionChecked(); + PreparedStatement statement = connection.prepareStatement(query)) { + for (int i = 0; i < cols.size(); i++) statement.setObject(i + 1, toSqlValue(cols.get(i).getValue())); + statement.setObject(cols.size() + 1, toUuidValue(index)); + if (statement.executeUpdate() == 0) requireExistingUpdateTarget(connection, index); + } + if (direct) com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyProviderCommit(index, cols.stream().map(Column::getName).toList(), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB), this); + else com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyBatchProviderCommit(index, cols.stream().map(Column::getName).toList(), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB)); } else { - q.executeUpdate(); + Query q = new Query(mysql, query); + for (int i = 0; i < cols.size(); i++) q.setParameter(i + 1, toSqlValue(cols.get(i).getValue())); + q.setParameter(cols.size() + 1, toUuidValue(index)); + if (runAsync) q.executeUpdateAsync(); else q.executeUpdate(); } } catch (SQLException e) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.propagateLegacyBatchWriteFailure(index, e); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(index, e); debug(e); } } else { + if (superseded) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(index, new IllegalStateException("Cannot recreate a partially superseded row")); + return; + } insertQuery(index, cols); + if (direct) com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyProviderCommit(index, cols.stream().map(Column::getName).toList(), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB), this); + else if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyBatchPersistence(index)) + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyBatchProviderCommit(index, cols.stream().map(Column::getName).toList(), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB)); } } } @@ -916,25 +981,49 @@ public void update(String index, String column, DataValue value) { checkColumn(column, value.getType()); synchronized (updateLock) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectStaleLegacyBatch(index); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index) + && !com.bencodez.advancedcore.api.user.usercache.UserDataCache.isCurrentLegacyProviderKey(index, column, com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB))) return; if (getUuids().contains(index) || containsKeyQuery(index)) { String query = "UPDATE " + qi(tableName) + " SET " + qi(column) + "=? WHERE " + qi("uuid") + "=?;"; plugin.devDebug("DB QUERY: " + query); try { - Query prepared = new Query(mysql, query); - prepared.setParameter(1, toSqlValue(value)); - prepared.setParameter(2, toUuidValue(index)); - prepared.executeUpdate(); + if (com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(index)) { + try (Connection connection = mysql.getConnectionManager().getConnectionChecked(); + PreparedStatement statement = connection.prepareStatement(query)) { + statement.setObject(1, toSqlValue(value)); statement.setObject(2, toUuidValue(index)); + if (statement.executeUpdate() == 0) requireExistingUpdateTarget(connection, index); + } + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyProviderCommit(index, java.util.List.of(column), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB), this); + } else { + Query prepared = new Query(mysql, query); + prepared.setParameter(1, toSqlValue(value)); + prepared.setParameter(2, toUuidValue(index)); + prepared.executeUpdate(); + } } catch (SQLException e) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(index, e); debug(e); } } else { insert(index, column, value); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyProviderCommit(index, java.util.List.of(column), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, (dbType == DbType.MYSQL || dbType == DbType.MARIADB), this); } } } + private void requireExistingUpdateTarget(Connection connection, String index) throws SQLException { + String query = "SELECT 1 FROM " + qi(tableName) + " WHERE " + qi("uuid") + "=?"; + try (PreparedStatement check = connection.prepareStatement(query)) { + check.setObject(1, toUuidValue(index)); + try (ResultSet result = check.executeQuery()) { + if (!result.next()) throw new SQLException("Update target no longer exists"); + } + } + } + private void bindUuid(PreparedStatement statement, int parameter, String uuid) throws SQLException { statement.setObject(parameter, toUuidValue(uuid)); } 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 d213f4fee1..e5b902cd75 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 @@ -24,6 +24,12 @@ import com.bencodez.simpleapi.sql.sqlite.db.SQLite; public class UserTable extends com.bencodez.simpleapi.sql.sqlite.Table { + /** A rejected insert whose original row identity is no longer safe to recreate. */ + public static final class StaleRowWriteException extends IllegalStateException { + private static final long serialVersionUID = 1L; + public StaleRowWriteException(String message) { super(message); } + } + private static final ThreadLocal STRICT_ROW_READ = new ThreadLocal<>(); static String quoteIdentifier(String identifier) { if (identifier == null) { throw new IllegalArgumentException("SQL identifier cannot be null"); @@ -39,6 +45,43 @@ static String quoteIdentifier(String identifier) { private SQLite sqLite; private AdvancedCorePlugin plugin; + private volatile java.sql.Connection guardedConnection; + private final String directGuardName = "advancedcore_direct_" + Integer.toHexString(System.identityHashCode(this)); + private final org.sqlite.SQLiteCommitListener directCommitListener = new org.sqlite.SQLiteCommitListener() { + @Override public void onCommit() { com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectCommit(directCommitListener); } + @Override public void onRollback() { com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectRollback(directCommitListener); } + }; + /** Identity of this table's SQLite commit observation, without opening storage. */ + public Object legacyDirectProviderIdentity() { return directCommitListener; } + /** Subclasses must explicitly opt in before replay transactions bypass their overrides. */ + public boolean supportsAtomicReplayTransactions() { return getClass() == UserTable.class; } + + private boolean ensureDirectGuard(java.sql.Connection connection) throws java.sql.SQLException { + java.sql.Connection nativeConnection = connection; + if (!(nativeConnection instanceof org.sqlite.SQLiteConnection)) { + try { nativeConnection = connection.unwrap(org.sqlite.SQLiteConnection.class); } + catch (java.sql.SQLException unavailable) { return false; } + if (nativeConnection == null) return false; + } + if (guardedConnection == nativeConnection && !nativeConnection.isClosed()) return true; + org.sqlite.Function.create(nativeConnection, directGuardName, new org.sqlite.Function() { + @Override protected void xFunc() throws java.sql.SQLException { + result(com.bencodez.advancedcore.api.user.usercache.UserDataCache.observeSqliteDirectKey(value_text(0), value_text(1)) ? 1 : 0); + } + }); + ((org.sqlite.SQLiteConnection) nativeConnection).addCommitListener(directCommitListener); + guardedConnection = nativeConnection; + return true; + } + + private static String sqlLiteral(String value) { return "'" + value.replace("'", "''") + "'"; } + private String directGuard(String uuid, String key) { return directGuardName + "(" + sqlLiteral(uuid) + "," + sqlLiteral(key) + ")"; } + private void appendBalancedGuard(StringBuilder result, List columns, String uuid, String operator, int start, int end) { + if (end - start == 1) { result.append(directGuard(uuid, columns.get(start).getName())); return; } + int middle = start + (end - start) / 2; + result.append('('); appendBalancedGuard(result, columns, uuid, operator, start, middle); + result.append(operator); appendBalancedGuard(result, columns, uuid, operator, middle, end); result.append(')'); + } public UserTable(AdvancedCorePlugin plugin, String name, Collection columns) { this.name = name; @@ -411,46 +454,59 @@ public List getColumnsString() { } public ArrayList getExact(Column column) { + return getExact(column, Boolean.TRUE.equals(STRICT_ROW_READ.get())); + } + + /** Shared storage must not turn a failed row read into an empty snapshot. */ + public ArrayList getExactStrict(Column column) { + Boolean previous = STRICT_ROW_READ.get(); + STRICT_ROW_READ.set(Boolean.TRUE); + try { return getExact(column); } + finally { if (previous == null) STRICT_ROW_READ.remove(); else STRICT_ROW_READ.set(previous); } + } + + private ArrayList getExact(Column column, boolean strict) { ArrayList result = new ArrayList<>(); String query = "SELECT * FROM " + getName() + " WHERE `" + column.getName() + "`=?"; try { synchronized (object) { - PreparedStatement s = sqLite.getSQLConnection().prepareStatement(query); - if (column.getValue().isString()) { - s.setString(1, column.getValue().getString()); - } else if (column.getValue().isInt()) { - s.setInt(1, column.getValue().getInt()); - } else { - s.setBoolean(1, column.getValue().getBoolean()); - } - ResultSet rs = s.executeQuery(); - - if (rs.next()) { - for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { - String columnName = rs.getMetaData().getColumnLabel(i); - Column rCol = null; - if (plugin.getUserManager().getDataManager().isInt(columnName)) { - rCol = new Column(columnName, DataType.INTEGER); - rCol.setValue(new DataValueInt(rs.getInt(i))); - } else if (plugin.getUserManager().getDataManager().isBoolean(columnName)) { - rCol = new Column(columnName, DataType.BOOLEAN); - rCol.setValue(new DataValueBoolean(Boolean.valueOf(rs.getString(i)))); - } else { - rCol = new Column(columnName, DataType.STRING); - rCol.setValue(new DataValueString(rs.getString(i))); + try (PreparedStatement s = sqLite.getSQLConnection().prepareStatement(query)) { + if (column.getValue().isString()) { + s.setString(1, column.getValue().getString()); + } else if (column.getValue().isInt()) { + s.setInt(1, column.getValue().getInt()); + } else { + s.setBoolean(1, column.getValue().getBoolean()); + } + try (ResultSet rs = s.executeQuery()) { + if (rs.next()) { + for (int i = 1; i <= rs.getMetaData().getColumnCount(); i++) { + String columnName = rs.getMetaData().getColumnLabel(i); + Column rCol = null; + if (plugin.getUserManager().getDataManager().isInt(columnName)) { + rCol = new Column(columnName, DataType.INTEGER); + rCol.setValue(new DataValueInt(rs.getInt(i))); + } else if (plugin.getUserManager().getDataManager().isBoolean(columnName)) { + rCol = new Column(columnName, DataType.BOOLEAN); + rCol.setValue(new DataValueBoolean(Boolean.valueOf(rs.getString(i)))); + } else { + rCol = new Column(columnName, DataType.STRING); + rCol.setValue(new DataValueString(rs.getString(i))); + } + result.add(rCol); + } } - result.add(rCol); } } - rs.close(); - s.close(); return result; } } catch (SQLException e) { + if (strict) throw new IllegalStateException("Failed to read SQLite user row", e); e.printStackTrace(); } catch (ArrayIndexOutOfBoundsException e) { + if (strict) throw new IllegalStateException("Failed to read SQLite user row", e); } for (Column col : getColumns()) { @@ -620,48 +676,79 @@ public boolean hasColumn(Column column) { return getTableColumns().contains(column.getName()); } + private static boolean sameSqliteIdentifier(String left, String right) { + return com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey(left) + .equals(com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey(right)); + } + public void insert(List columns) { for (Column c : columns) { checkColumn(c); } - String query = "INSERT OR REPLACE INTO " + getName() + " ("; + String targetId = null; for (Column column : columns) { - if (columns.indexOf(column) < columns.size() - 1) { - query += "`" + column.getName() + "`, "; - } else { - query += "`" + column.getName() + "`) "; - } - } - query += "VALUES ("; - for (int i = 0; i < columns.size(); i++) { - if (i < columns.size() - 1) { - query += "?, "; - } else { - query += "?)"; - } - } - query += ";"; + if (targetId == null && sameSqliteIdentifier(column.getName(), primaryKey.getName()) && column.getValue() != null && column.getValue().isString()) + targetId = column.getValue().getString(); + } + final String rowId = targetId; + boolean legacyBatch = com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyBatchPersistence(targetId); + boolean direct = com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence(); + if (direct && targetId != null) com.bencodez.advancedcore.api.user.usercache.UserDataCache.bindLegacyDirectRow(targetId, true); + if (direct) com.bencodez.advancedcore.api.user.usercache.UserDataCache.classifySqliteDirectStatement(true); + if (direct) com.bencodez.advancedcore.api.user.usercache.UserDataCache.setSqliteMoveDestination(targetId, null); try { - PreparedStatement s = sqLite.getSQLConnection().prepareStatement(query); - for (int i = 0; i < columns.size(); i++) { - if (columns.get(i).getValue() != null) { - if (columns.get(i).getValue().isString()) { - s.setString(i + 1, columns.get(i).getValue().getString()); - } else if (columns.get(i).getValue().isInt()) { - s.setInt(i + 1, columns.get(i).getValue().getInt()); - } else { - s.setBoolean(i + 1, columns.get(i).getValue().getBoolean()); - } - } else { - s.setString(i + 1, ""); + java.sql.Connection connection = sqLite.getSQLConnection(); + boolean guarded = direct && targetId != null && ensureDirectGuard(connection); + boolean directStatement = guarded; + java.util.List mutable = new ArrayList<>(); + for (Column column : columns) if (!sameSqliteIdentifier(column.getName(), primaryKey.getName())) mutable.add(column); + guarded = guarded && !mutable.isEmpty(); + boolean guardedPrimaryOnly = directStatement && mutable.isEmpty(); + StringBuilder query = new StringBuilder(guarded ? "INSERT INTO " : legacyBatch || (directStatement && mutable.isEmpty()) ? "INSERT OR IGNORE INTO " : "INSERT OR REPLACE INTO "); + query.append(quoteIdentifier(getName())).append(" ("); + for (int i = 0; i < columns.size(); i++) { if (i != 0) query.append(", "); query.append(quoteIdentifier(columns.get(i).getName())); } + query.append(guarded || guardedPrimaryOnly ? ") SELECT " : ") VALUES ("); + for (int i = 0; i < columns.size(); i++) { if (i != 0) query.append(", "); query.append("?"); } + if (guarded) { + query.append(" WHERE ").append(directGuard(rowId, primaryKey.getName())).append(" AND ("); appendBalancedGuard(query, mutable, rowId, " OR ", 0, mutable.size()); + query.append(") AND (("); appendBalancedGuard(query, mutable, rowId, " AND ", 0, mutable.size()); + query.append(") OR EXISTS (SELECT 1 FROM ") + .append(quoteIdentifier(getName())).append(" WHERE ").append(quoteIdentifier(primaryKey.getName())).append("=").append(sqlLiteral(targetId)).append("))") + .append(" ON CONFLICT(").append(quoteIdentifier(primaryKey.getName())).append(") DO UPDATE SET "); + for (int i = 0; i < mutable.size(); i++) { + if (i != 0) query.append(", "); + Column column = mutable.get(i); String name = quoteIdentifier(column.getName()); + query.append(name).append("=CASE WHEN ").append(directGuard(rowId, column.getName())) + .append(" THEN excluded.").append(name).append(" ELSE ").append(quoteIdentifier(getName())).append(".").append(name).append(" END"); + } + } else if (guardedPrimaryOnly) query.append(" WHERE ").append(directGuard(rowId, primaryKey.getName())); + else query.append(")"); + try (PreparedStatement statement = connection.prepareStatement(query.toString())) { + for (int i = 0; i < columns.size(); i++) { + if (columns.get(i).getValue() == null) statement.setString(i + 1, ""); + else bindValue(statement, i + 1, columns.get(i)); + } + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectStaleLegacyBatch(targetId); + int inserted; + if (directStatement) com.bencodez.advancedcore.api.user.usercache.UserDataCache.beginSqliteDirectExecution(true, legacyDirectProviderIdentity()); + try { inserted = statement.executeUpdate(); } + finally { if (directStatement) com.bencodez.advancedcore.api.user.usercache.UserDataCache.endSqliteDirectExecution(); } + if (legacyBatch && inserted == 0) com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectLegacyBatchInsertConflict(targetId); + if (legacyBatch && inserted > 0) com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyBatchProviderCommit(targetId, columns.stream().map(Column::getName).toList(), com.bencodez.advancedcore.api.user.UserStorage.SQLITE, false); + if (directStatement && !guarded && inserted == 0 && com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasAdmittedSqliteDirectKey(primaryKey.getName()) + && com.bencodez.advancedcore.api.user.usercache.UserDataCache.isCurrentLegacyDirectKey(rowId, primaryKey.getName())) + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(targetId, new IllegalStateException("SQLite primary-only insert encountered an existing row")); + if (guarded && inserted == 0 && mutable.stream().anyMatch(column -> com.bencodez.advancedcore.api.user.usercache.UserDataCache.isCurrentLegacyDirectKey(rowId, column.getName()))) { + StaleRowWriteException failure = new StaleRowWriteException("SQLite direct write could not safely recreate a partially superseded row"); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(targetId, failure); + throw failure; } } - s.executeUpdate(); - s.close(); } catch (SQLException e) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.propagateLegacyBatchWriteFailure(targetId, e); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(targetId, e); e.printStackTrace(); } - } public List> search(Column column) { @@ -731,36 +818,85 @@ public void update(Column primaryKey, List columns) { } if (containsKey(primaryKey.getValue().toString())) { synchronized (object) { + columns = com.bencodez.advancedcore.api.user.usercache.UserDataCache.filterConfirmedLegacyBatchColumns(primaryKey.getValue().getString(), columns, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, legacyDirectProviderIdentity()); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectStaleLegacyBatch(primaryKey.getValue().getString()); + if (columns.isEmpty()) return; + com.bencodez.advancedcore.api.user.usercache.UserDataCache.classifySqliteDirectStatement(false); + java.sql.Connection connection = sqLite.getSQLConnection(); + boolean guarded = false; + try { guarded = com.bencodez.advancedcore.api.user.usercache.UserDataCache.hasLegacyDirectPersistence() && ensureDirectGuard(connection); } + catch (SQLException failure) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(primaryKey.getValue().getString(), failure); + failure.printStackTrace(); + return; + } StringBuilder query = new StringBuilder("UPDATE ").append(getName()).append(" SET "); + String moveDestination = null; + for (Column column : columns) if (sameSqliteIdentifier(column.getName(), primaryKey.getName()) && column.getValue() != null && column.getValue().isString()) moveDestination = column.getValue().getString(); + if (guarded) com.bencodez.advancedcore.api.user.usercache.UserDataCache.setSqliteMoveDestination(primaryKey.getValue().getString(), moveDestination); for (int i = 0; i < columns.size(); i++) { - query.append("`").append(columns.get(i).getName()).append("`=?"); + String name = quoteIdentifier(columns.get(i).getName()); + query.append(name).append("="); + if (guarded) + query.append("CASE WHEN ").append(directGuard(primaryKey.getValue().getString(), columns.get(i).getName())).append(" THEN ? ELSE ").append(name).append(" END"); + else query.append("?"); if (i != columns.size() - 1) { query.append(", "); } } query.append(" WHERE `").append(primaryKey.getName()).append("`=?"); - try (PreparedStatement s = sqLite.getSQLConnection().prepareStatement(query.toString())) { + if (guarded) query.append(" AND ").append(directGuard(primaryKey.getValue().getString(), primaryKey.getName())); + try (PreparedStatement s = connection.prepareStatement(query.toString())) { int parameter = 1; for (Column column : columns) { bindValue(s, parameter++, column); } bindValue(s, parameter, primaryKey); - s.executeUpdate(); + if (guarded) com.bencodez.advancedcore.api.user.usercache.UserDataCache.beginSqliteDirectExecution(false, legacyDirectProviderIdentity()); + int updated; + try { updated = s.executeUpdate(); } + finally { if (guarded) com.bencodez.advancedcore.api.user.usercache.UserDataCache.endSqliteDirectExecution(); } + if (updated == 0) { + String row = primaryKey.getValue().getString(); + StaleRowWriteException failure = new StaleRowWriteException("SQLite update target disappeared before the write"); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.propagateLegacyBatchWriteFailure(row, failure); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(row, failure); + } + else com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyBatchProviderCommit(primaryKey.getValue().getString(), columns.stream().map(Column::getName).toList(), com.bencodez.advancedcore.api.user.UserStorage.SQLITE, false); } catch (SQLException e) { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.propagateLegacyBatchWriteFailure(primaryKey.getValue().getString(), e); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyDirectWriteFailure(primaryKey.getValue().getString(), e); e.printStackTrace(); } } } else { + String row = primaryKey.getValue().getString(); + long claim = com.bencodez.advancedcore.api.user.usercache.UserDataCache.confirmedLegacyBatchClaimSerial(row); + columns = com.bencodez.advancedcore.api.user.usercache.UserDataCache.filterConfirmedLegacyBatchColumns(row, columns, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, legacyDirectProviderIdentity()); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectStaleLegacyBatch(row); + if (claim != com.bencodez.advancedcore.api.user.usercache.UserDataCache.confirmedLegacyBatchClaimSerial(row)) { + if (!containsKey(row)) com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectLegacyBatchInsertConflict(row); + if (!columns.isEmpty()) update(primaryKey, columns); + return; + } boolean addPrimary = true; for (Column col : columns) { if (col.getName().equals("uuid")) { addPrimary = false; } } - if (addPrimary) { - columns.add(primaryKey); + List insertColumns = new ArrayList<>(columns); + if (addPrimary) insertColumns.add(primaryKey); + try { + insert(insertColumns); + } catch (com.bencodez.advancedcore.api.user.usercache.UserDataCache.StaleLegacyBatchException stale) { + claim = com.bencodez.advancedcore.api.user.usercache.UserDataCache.confirmedLegacyBatchClaimSerial(row); + List remaining = com.bencodez.advancedcore.api.user.usercache.UserDataCache.filterConfirmedLegacyBatchColumns(row, columns, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, legacyDirectProviderIdentity()); + if (claim == com.bencodez.advancedcore.api.user.usercache.UserDataCache.confirmedLegacyBatchClaimSerial(row)) throw stale; + com.bencodez.advancedcore.api.user.usercache.UserDataCache.rejectStaleLegacyBatch(row); + if (!containsKey(row)) throw stale; + if (!remaining.isEmpty()) update(primaryKey, remaining); } - insert(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 c5b9ccd622..067cc75872 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 @@ -552,7 +552,8 @@ private CompletionStage closeUserStorageAfterSharedRetirement() { } CompletionStage retirement = admission.manager().closeSharedRuntimeAsyncCompletion(closeMysql); if (retirement == null) { - return closeUserStorageOwnerAsync(admission); + return admission.manager().shutdownLegacyIntegerMutations() + .thenCompose(ignored -> closeUserStorageOwnerAsync(admission)); } return retirement; } @@ -601,6 +602,11 @@ private UserStorageAdmission captureUserStorageAdmission() { } private void closeCurrentUserStorageOwner(UserStorageAdmission admission) { + if (admission.manager() != null) { + admission.manager().closeLegacyIntegerAdmissionForShutdown(); + if (admission.manager().hasUnsettledLegacyIntegerMutations()) + throw new IllegalStateException("Legacy integer mutations have not settled"); + } if (!userStorageOwnerClosed.compareAndSet(false, true)) return; try { AdvancedCorePlugin.UserStorageOwner owner = plugin.getNativeUserStorageOwner(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java index 9ea7a707aa..ddce3e604a 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java @@ -139,7 +139,8 @@ private Consumer exclusiveCacheGate(UUID uuid) { } private void bind(UserDataCache cache, UUID uuid) { - if (backend == null) return; + SqlUserBackend binding = backend; + if (binding == 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 @@ -151,7 +152,7 @@ private void bind(UserDataCache cache, UUID uuid) { throw new IllegalStateException("Shared SQL backend is unavailable"); } selected.user(uuid).writeValues(selected.storageType(), values); - }, cacheGate(uuid), exclusiveCacheGate(uuid)); + }, cacheGate(uuid), exclusiveCacheGate(uuid), binding.storageType()); } @Override public void requireBlockingAllowed() { @@ -174,20 +175,26 @@ private void bind(UserDataCache cache, UUID uuid) { } @Override public void populate(UUID uuid, HashMap values) { + populate(uuid, values, false); + } + + @Override public void populate(UUID uuid, HashMap values, boolean completeRow) { Boolean admitted = manager.withCacheMapReadAdmission(() -> { UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); - cache.updateCachePreservingPending(values); + if (completeRow) cache.updateCompleteCachePreservingPending(values); + else cache.updateCachePreservingPending(values); return Boolean.TRUE; }); // Test doubles and legacy adapters may not implement the optional admission hook. - if (admitted == null) populateWithoutMapAdmission(uuid, values); + if (admitted == null) populateWithoutMapAdmission(uuid, values, completeRow); } - private void populateWithoutMapAdmission(UUID uuid, HashMap values) { + private void populateWithoutMapAdmission(UUID uuid, HashMap values, boolean completeRow) { UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); - cache.updateCachePreservingPending(values); + if (completeRow) cache.updateCompleteCachePreservingPending(values); + else cache.updateCachePreservingPending(values); } private record CachePopulation(UUID uuid, UserDataCache cache, long version) implements PopulationToken {} @@ -205,12 +212,17 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp } @Override public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { + return completePopulation(uuid, values, token, false); + } + + @Override public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token, boolean completeRow) { 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())); + populated.set(completeRow ? current.updateCompleteSharedSnapshot(values, expected.version()) + : current.updateSharedSnapshot(values, expected.version())); return current; }); return populated.get(); @@ -218,7 +230,8 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp 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())); + populated.set(completeRow ? current.updateCompleteSharedSnapshot(values, expected.version()) + : current.updateSharedSnapshot(values, expected.version())); return current; }); } 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 index 904e92b49a..98754af8b1 100644 --- 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 @@ -69,6 +69,23 @@ public BukkitSqlUserBackend(AdvancedCorePlugin plugin, UserStorage storageType, } @Override public UserStorage storageType() { return storageType; } + @Override public boolean supportsAtomicUserTransactions() { + return storageType == UserStorage.MYSQL ? mysql().supportsAtomicReplayTransactions() : table().supportsAtomicReplayTransactions(); + } + + @Override public boolean hasAuthoritativeRowReads() { + try { + if (storageType == UserStorage.MYSQL) { + Class type = mysql().getClass(); + return type.getMethod("getExact", String.class).getDeclaringClass() == MySQL.class + && type.getMethod("getExactQuery", Column.class).getDeclaringClass() == MySQL.class + && type.getMethod("getExactStrict", String.class).getDeclaringClass() == MySQL.class; + } + Class type = table().getClass(); + return type.getMethod("getExact", Column.class).getDeclaringClass() == UserTable.class + && type.getMethod("getExactStrict", Column.class).getDeclaringClass() == UserTable.class; + } catch (ReflectiveOperationException | SecurityException unavailable) { return false; } + } @Override public SqlUserStorage user(UUID uuid) { @@ -134,7 +151,7 @@ private List read(UserStorage storage, UUID uuid) { requireOpen(); requireStorage(storage); if (storage == UserStorage.MYSQL) return mysql().getExactStrict(uuid.toString()); - synchronized (sqliteOperations) { return table().getExact(primary(uuid)); } + synchronized (sqliteOperations) { return table().getExactStrict(primary(uuid)); } } private boolean contains(UserStorage storage, UUID uuid) { 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 8cbf11b943..a7c9e30df7 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 @@ -19,6 +19,17 @@ /** Adapts the existing SQL providers and the shared-runtime replacement route. */ public final class BukkitSqlUserStorage implements SqlUserStorage { + private record StrictOwnerScope(AdvancedCorePlugin plugin, AdvancedCorePlugin.UserStorageOwner owner) { } + private static final ThreadLocal STRICT_OWNER = new ThreadLocal<>(); + + /** Bind a strict legacy read to the already captured native provider for this call. */ + public static T withStrictOwner(AdvancedCorePlugin plugin, AdvancedCorePlugin.UserStorageOwner owner, Supplier operation) { + Objects.requireNonNull(operation, "operation"); + StrictOwnerScope previous = STRICT_OWNER.get(); + STRICT_OWNER.set(new StrictOwnerScope(plugin, owner)); + try { return operation.get(); } + finally { if (previous == null) STRICT_OWNER.remove(); else STRICT_OWNER.set(previous); } + } private final Supplier plugin; private final Supplier uuid; @@ -61,6 +72,60 @@ public List readRow(UserStorage storage) { ? owner().getMysql().getExact(uuid.get()) : owner().getSQLiteUserTable().getExact(primary())); } + /** Read a complete row without treating a failed legacy query as defaults. */ + public List readRowStrict(UserStorage storage) { + Objects.requireNonNull(storage, "storage"); + return routed(storage, (type, target) -> target.readRow(type), () -> { + try { + StrictOwnerScope scope = STRICT_OWNER.get(); + AdvancedCorePlugin pluginOwner = owner(); + AdvancedCorePlugin.UserStorageOwner captured = scope != null && scope.plugin() == pluginOwner ? scope.owner() : null; + if (captured != null && captured.storageType() != storage) throw new IllegalStateException("Strict legacy storage changed while reading"); + if (storage == UserStorage.MYSQL) { + com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL table = captured == null ? pluginOwner.getMysql() : captured.mysql(); + if (table.getClass().getMethod("getExact", String.class).getDeclaringClass() + != com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class + || table.getClass().getMethod("getExactQuery", Column.class).getDeclaringClass() + != com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class + || table.getClass().getMethod("getExactStrict", String.class).getDeclaringClass() + != com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class) return null; + return table.getExactStrict(uuid.get()); + } + com.bencodez.advancedcore.api.user.userstorage.sql.UserTable table = captured == null ? pluginOwner.getSQLiteUserTable() : captured.table(); + if (table.getClass().getMethod("getExact", Column.class).getDeclaringClass() + != com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class + || table.getClass().getMethod("getExactStrict", Column.class).getDeclaringClass() + != com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class) return null; + return table.getExactStrict(primary()); + } catch (NoSuchMethodException unavailable) { return null; } + }); + } + + /** Inspect legacy reader capability without issuing a query. Missing providers are failures. */ + public boolean supportsStrictLegacyRowReads(UserStorage storage) { + Objects.requireNonNull(storage, "storage"); + StrictOwnerScope scope = STRICT_OWNER.get(); + AdvancedCorePlugin pluginOwner = owner(); + AdvancedCorePlugin.UserStorageOwner captured = scope != null && scope.plugin() == pluginOwner + ? scope.owner() : pluginOwner.getNativeUserStorageOwner(); + if (captured == null) throw new IllegalStateException("Native user storage owner is unavailable"); + if (captured.storageType() != storage) + throw new IllegalStateException("Strict legacy storage changed while inspecting capability"); + try { + if (storage == UserStorage.MYSQL) { + com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL table = Objects.requireNonNull(captured.mysql(), "MySQL user storage is unavailable"); + Class type = table.getClass(); + return type.getMethod("getExact", String.class).getDeclaringClass() == com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class + && type.getMethod("getExactQuery", Column.class).getDeclaringClass() == com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class + && type.getMethod("getExactStrict", String.class).getDeclaringClass() == com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class; + } + com.bencodez.advancedcore.api.user.userstorage.sql.UserTable table = Objects.requireNonNull(captured.table(), "SQLite user storage is unavailable"); + Class type = table.getClass(); + return type.getMethod("getExact", Column.class).getDeclaringClass() == com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class + && type.getMethod("getExactStrict", Column.class).getDeclaringClass() == com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class; + } catch (NoSuchMethodException | SecurityException unavailable) { return false; } + } + @Override public boolean contains(UserStorage storage) { Objects.requireNonNull(storage, "storage"); @@ -85,10 +150,17 @@ public void write(UserStorage storage, String key, DataValue value) { if (storage == UserStorage.SQLITE) { ArrayList columns = new ArrayList<>(); Column primary = primary(); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.bindLegacyDirectRow(primary.getValue().getString(), true); columns.add(primary); columns.add(new Column(key, value)); - owner().getSQLiteUserTable().update(primary, columns); - } else owner().getMysql().update(uuid.get(), key, value); + com.bencodez.advancedcore.api.user.userstorage.sql.UserTable captured = com.bencodez.advancedcore.api.user.usercache.UserDataCache.capturedNestedSqlite(); + (captured == null ? owner().getSQLiteUserTable() : captured).update(primary, columns); + } else { + String row = uuid.get(); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.bindLegacyDirectRow(row, false); + com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL captured = com.bencodez.advancedcore.api.user.usercache.UserDataCache.capturedNestedMysql(); + (captured == null ? owner().getMysql() : captured).update(row, key, value); + } return null; }); } @@ -103,14 +175,20 @@ public void writeValues(UserStorage storage, HashMap values) 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); + String row = uuid.get(); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.bindLegacyDirectRow(row, false); + owner().getMysql().update(row, 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())); } - if (!columns.isEmpty()) owner().getSQLiteUserTable().update(primary(), columns); + if (!columns.isEmpty()) { + Column primary = primary(); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.bindLegacyDirectRow(primary.getValue().getString(), true); + owner().getSQLiteUserTable().update(primary, columns); + } } return null; }); 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 index 8a32a9f8da..7ba69055c3 100644 --- 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 @@ -87,8 +87,9 @@ public HashMap populate(UUID 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); + List row = readStorageRow(uuid); + HashMap values = SqlUserDataAccess.convert(row); + return cacheOwner.completePopulation(uuid, values, token, row != null && backend.hasAuthoritativeRowReads()); } public int startupForEach(BiConsumer> consumer, boolean populateCache) { @@ -189,7 +190,8 @@ private T transactionInternal(UUID uuid, UserStorage expectedStorage, Map row = readStorageRow(uuid); + cacheOwner.populate(uuid, SqlUserDataAccess.convert(row), row != null && backend.hasAuthoritativeRowReads()); } catch (RuntimeException | Error recoveryFailure) { failure.addSuppressed(recoveryFailure); } finally { 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 index 948fd8123d..11581047e9 100644 --- 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 @@ -19,6 +19,11 @@ public interface UserCacheOwner { DataValue getIfPresent(UUID uuid, String key); void populate(UUID uuid, HashMap values); + /** An absent provider row is ambiguous; only a non-null full row can prove missing keys. */ + default void populate(UUID uuid, HashMap values, boolean completeRow) { + populate(uuid, values); + } + interface PopulationToken {} default PopulationToken beginPopulation(UUID uuid) { return null; } @@ -29,6 +34,11 @@ default HashMap completePopulation(UUID uuid, HashMap completePopulation(UUID uuid, HashMap values, + PopulationToken token, boolean completeRow) { + return completePopulation(uuid, values, token); + } + void queueChange(UUID uuid, String key, DataValue value); void flush(UUID uuid, SqlUserStorage storage); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/SqlUserStorage.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/SqlUserStorage.java index 2c4612b2e3..ffcc1009f1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/SqlUserStorage.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/SqlUserStorage.java @@ -22,6 +22,14 @@ * expose a caller extension of their existing user-row transaction. */ public interface SqlUserStorage { + /** + * A replay callback may reject before it performs any write or external + * effect. Never throw this after modifying transaction state: callers use it + * to distinguish a definite rejection from an uncertain commit. + */ + final class ReplayPreconditionException extends IllegalStateException { + public ReplayPreconditionException(String message) { super(message); } + } List readRow(UserStorage storage); boolean contains(UserStorage storage); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java index 966c567343..2cb40bfb84 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java @@ -52,6 +52,8 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema } @Override public UserStorage storageType() { return UserStorage.MYSQL; } + @Override public boolean hasAuthoritativeRowReads() { return true; } + @Override public boolean supportsAtomicUserTransactions() { return true; } @Override public SqlUserStorage user(UUID uuid) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java index 20062bbe5e..6543e03b31 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java @@ -12,6 +12,12 @@ public interface SqlUserBackend extends AutoCloseable { /** Materialized compatibility API is intentionally bounded by concrete SQL backends. */ int MAX_MATERIALIZED_USERS = 100_000; + /** True only when a successful row read contains every persisted column. */ + default boolean hasAuthoritativeRowReads() { return false; } + + /** Explicit opt-in: user transactions atomically commit caller-owned SQL and user values. */ + default boolean supportsAtomicUserTransactions() { return false; } + UserStorage storageType(); SqlUserStorage user(UUID uuid); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java index d42abbdbaa..c5e748f4ba 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqliteUserBackend.java @@ -48,6 +48,8 @@ public SqliteUserBackend(Path dataDirectory, String databaseName, String tableNa public Path databaseFile() { return databaseFile; } @Override public UserStorage storageType() { return UserStorage.SQLITE; } + @Override public boolean hasAuthoritativeRowReads() { return true; } + @Override public boolean supportsAtomicUserTransactions() { return true; } @Override public SqlUserStorage user(UUID uuid) { 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 88f295549b..c8e6f65934 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 @@ -1745,6 +1745,123 @@ public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, Conf assertEquals("second-value", placeholders.get("second")); } + @Test + void checkpointContextUsesTheExactSelectionAndIgnoresStaleFreshPlaceholders() { + List contexts = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Selected") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { + Reward.replaySelection(p, () -> "chosen"); return CompletableFuture.completedFuture(null); + } + @Override public CompletionStage onReplayCheckpointPersisted(Reward r, AdvancedCoreUser u, String occurrence, String key, boolean selected) { + contexts.add(selected); return CompletableFuture.completedFuture(null); + } + }); + handler.getInjectedRewards().add(new RewardInject("Absent") { + @Override public Object onRewardRequest(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { return null; } + @Override public CompletionStage onReplayCheckpointPersisted(Reward r, AdvancedCoreUser u, String occurrence, String key, boolean selected) { + contexts.add(selected); return CompletableFuture.completedFuture(null); + } + }); + var placeholders = new HashMap(); + String staleKey = "__advancedcore_replay_selection_" + java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("AsyncReward/1".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + placeholders.put(staleKey, "c3RhbGU"); + reward.giveInjectedRewardsAsync(user, placeholders).toCompletableFuture().join(); + assertEquals(List.of(true, false), contexts); + } + + @Test + void freshDurableCheckpointDoesNotAcceptStaleCallerSelection() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { invocation.getArgument(0, Runnable.class).run(); return null; }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); List observed = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Absent") { + @Override public Object onRewardRequest(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { return null; } + @Override public CompletionStage onReplayCheckpointPersisted(Reward r, AdvancedCoreUser u, String occurrence, String key, boolean selected) { + observed.add(selected); return CompletableFuture.completedFuture(null); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + var constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); constructor.setAccessible(true); + Object state = constructor.newInstance(null, null, false); + var setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", java.util.function.Consumer.class); setConsumer.setAccessible(true); + var persisted = new java.util.concurrent.atomic.AtomicReference(); + setConsumer.invoke(state, (java.util.function.Consumer) persisted::set); + var placeholders = new HashMap(); + String key = "__advancedcore_replay_selection_" + java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("AsyncReward/0".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + placeholders.put(key, "c3RhbGU"); + var replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); replay.setAccessible(true); + @SuppressWarnings("unchecked") CompletionStage delivery = (CompletionStage) replay.invoke(reward, user, placeholders, 0, state, "AsyncReward", "fresh"); + delivery.toCompletableFuture().join(); assertEquals(List.of(false), observed); + var checkpoint = persisted.get(); assertNotNull(checkpoint); + Object restored = constructor.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false); + @SuppressWarnings("unchecked") CompletionStage resumed = (CompletionStage) replay.invoke(reward, user, + checkpoint.getPlaceholders(), 0, restored, "AsyncReward", "fresh"); + resumed.toCompletableFuture().join(); assertEquals(List.of(false, false), observed); + } + + @Test + void freshFailureDoesNotExportStaleFutureSelectionIntoRetry() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { invocation.getArgument(0, Runnable.class).run(); return null; }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + handler.getInjectedRewards().add(new RewardInject("First") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { + return CompletableFuture.failedFuture(new IllegalStateException("synthetic failure")); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + var constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); constructor.setAccessible(true); + Object state = constructor.newInstance(null, null, false); + var setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", java.util.function.Consumer.class); setConsumer.setAccessible(true); + setConsumer.invoke(state, (java.util.function.Consumer) checkpoint -> {}); + var placeholders = new HashMap(); + String staleKey = "__advancedcore_replay_selection_" + java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("AsyncReward/1".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + placeholders.put(staleKey, "c3RhbGU"); + var replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); replay.setAccessible(true); + @SuppressWarnings("unchecked") CompletionStage delivery = (CompletionStage) replay.invoke(reward, user, placeholders, 0, state, "AsyncReward", "fresh"); + var failure = assertThrows(java.util.concurrent.CompletionException.class, () -> delivery.toCompletableFuture().join()); + assertTrue(failure.getCause() instanceof Reward.RewardReplayFailure); + assertFalse(((Reward.RewardReplayFailure) failure.getCause()).getReplayPlaceholders().containsKey(staleKey)); + } + + @Test + void selectedCheckpointRetryReceivesSameContextWithoutRepeatingTheInjection() throws Exception { + ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); + doAnswer(invocation -> { invocation.getArgument(0, Runnable.class).run(); return null; }).when(storageExecutor).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(storageExecutor); + AtomicInteger injections = new AtomicInteger(); List observed = new ArrayList<>(); + handler.getInjectedRewards().add(new RewardInject("Selected") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { throw new AssertionError(); } + @Override public CompletionStage onRewardRequestAsync(Reward r, AdvancedCoreUser u, ConfigurationSection d, HashMap p) { + injections.incrementAndGet(); Reward.replaySelection(p, () -> "value"); return CompletableFuture.completedFuture(null); + } + @Override public CompletionStage onReplayCheckpointPersisted(Reward r, AdvancedCoreUser u, String occurrence, String key, boolean selected) { + observed.add(selected); return observed.size() == 1 ? CompletableFuture.failedFuture(new IllegalStateException("retry")) : CompletableFuture.completedFuture(null); + } + }); + Class stateType = Class.forName("com.bencodez.advancedcore.api.rewards.Reward$ReplayState"); + var constructor = stateType.getDeclaredConstructor(Map.class, Map.class, boolean.class); constructor.setAccessible(true); + Object replayState = constructor.newInstance(null, null, false); + var setConsumer = stateType.getDeclaredMethod("setCheckpointConsumer", java.util.function.Consumer.class); setConsumer.setAccessible(true); + var persisted = new java.util.concurrent.atomic.AtomicReference(); + setConsumer.invoke(replayState, (java.util.function.Consumer) persisted::set); + var replay = Reward.class.getDeclaredMethod("giveInjectedRewardsAsync", AdvancedCoreUser.class, HashMap.class, int.class, stateType, String.class, String.class); replay.setAccessible(true); + @SuppressWarnings("unchecked") CompletionStage first = (CompletionStage) replay.invoke(reward, user, new HashMap<>(), 0, replayState, "AsyncReward", "one"); + assertThrows(java.util.concurrent.CompletionException.class, () -> first.toCompletableFuture().join()); + var checkpoint = persisted.get(); assertNotNull(checkpoint); + Object restored = constructor.newInstance(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints(), false); + @SuppressWarnings("unchecked") CompletionStage retry = (CompletionStage) replay.invoke(reward, user, checkpoint.getPlaceholders(), 0, restored, "AsyncReward", "one"); + retry.toCompletableFuture().join(); assertEquals(1, injections.get()); assertEquals(List.of(true, true), observed); + } + @Test void checkpointWriteRunsOffServerThreadAndGatesTheNextInjection() throws Exception { ScheduledExecutorService storageExecutor = mock(ScheduledExecutorService.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlFailurePropagationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlFailurePropagationTest.java index 621f5b40d5..0f7a9afb6b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlFailurePropagationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlFailurePropagationTest.java @@ -24,8 +24,834 @@ import com.bencodez.advancedcore.api.player.UuidLookup; class BukkitSqlFailurePropagationTest { + private static class CustomMysql extends MySQL { CustomMysql() { super(null, null, null); } } + private static class ParticipatingMysql extends CustomMysql { @Override public boolean supportsAtomicReplayTransactions() { return true; } } + @Test void replayTransactionsRequireExplicitSubclassParticipation() { + var plugin = mock(AdvancedCorePlugin.class); + var primary = new com.bencodez.simpleapi.sql.Column("uuid", com.bencodez.simpleapi.sql.DataType.STRING); + var nativeTable = new com.bencodez.advancedcore.api.user.userstorage.sql.UserTable(plugin, "Users", java.util.List.of(), primary); + var customTable = new com.bencodez.advancedcore.api.user.userstorage.sql.UserTable(plugin, "Users", java.util.List.of(), primary) { }; + var participating = new com.bencodez.advancedcore.api.user.userstorage.sql.UserTable(plugin, "Users", java.util.List.of(), primary) { + @Override public boolean supportsAtomicReplayTransactions() { return true; } + }; + assertTrue(new BukkitSqlUserBackend(plugin, UserStorage.SQLITE, null, nativeTable).supportsAtomicUserTransactions()); + assertFalse(new BukkitSqlUserBackend(plugin, UserStorage.SQLITE, null, customTable).supportsAtomicUserTransactions()); + assertTrue(new BukkitSqlUserBackend(plugin, UserStorage.SQLITE, null, participating).supportsAtomicUserTransactions()); + var nativeMysql = mock(MySQL.class, CALLS_REAL_METHODS); var customMysql = mock(CustomMysql.class, CALLS_REAL_METHODS); + var optedInMysql = mock(ParticipatingMysql.class, CALLS_REAL_METHODS); + assertTrue(new BukkitSqlUserBackend(plugin, UserStorage.MYSQL, nativeMysql, null).supportsAtomicUserTransactions()); + assertFalse(new BukkitSqlUserBackend(plugin, UserStorage.MYSQL, customMysql, null).supportsAtomicUserTransactions()); + assertTrue(new BukkitSqlUserBackend(plugin, UserStorage.MYSQL, optedInMysql, null).supportsAtomicUserTransactions()); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + assertTrue(new com.bencodez.advancedcore.api.user.UserData(user).supportsAtomicReplayTransactions()); + assertFalse(new com.bencodez.advancedcore.api.user.UserData(user) { }.supportsAtomicReplayTransactions()); + assertTrue(new com.bencodez.advancedcore.api.user.UserData(user) { + @Override public boolean supportsAtomicReplayTransactions() { return true; } + }.supportsAtomicReplayTransactions()); + } + @Test void nativeMysqlAsyncNestedSameKeyDrainsBeforeDeferredWrite() throws Exception { nativeMysqlAsyncNestedSameKey(false, false, false); } + @Test void nativeMysqlAsyncNestedSameKeyDrainsAfterDeferredWrite() throws Exception { nativeMysqlAsyncNestedSameKey(true, false, false); } + @Test void nativeMysqlAsyncNestedSameKeyKeepsCapturedProviderAfterReplacement() throws Exception { nativeMysqlAsyncNestedSameKey(false, true, false); } + @Test void nativeMysqlAsyncNestedSameKeyRetainsFailedDeferredWrite() throws Exception { nativeMysqlAsyncNestedSameKey(false, false, true); } + @Test void nativeMysqlAsyncNestedCaseAliasDrainsBeforeDeferredWrite() throws Exception { nativeMysqlAsyncNestedSameKey(false, false, false, "x"); } + @Test void nativeMysqlAsyncNestedCaseAliasDrainsAfterDeferredWrite() throws Exception { nativeMysqlAsyncNestedSameKey(true, false, false, "x"); } + private void nativeMysqlAsyncNestedSameKey(boolean completeFirst, boolean replaceProvider, boolean failDeferred) throws Exception { + nativeMysqlAsyncNestedSameKey(completeFirst, replaceProvider, failDeferred, "X"); + } + private void nativeMysqlAsyncNestedSameKey(boolean completeFirst, boolean replaceProvider, boolean failDeferred, String nestedKey) throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + Fixture replacement = new Fixture(DbType.MYSQL); replacement.set(AbstractSqlTable.class, "updateLock", new Object()); replacement.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); + when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(f.plugin.getMysql()).thenReturn(f.table); when(f.plugin.getTimer()).thenReturn(timer); + doNothing().when(f.table).checkColumn(anyString(), any()); + var tasks = new java.util.ArrayList(); + var executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + doAnswer(call -> { Runnable task = call.getArgument(0); if (completeFirst) executor.submit(task).get(5, java.util.concurrent.TimeUnit.SECONDS); else tasks.add(task); return null; }).when(timer).execute(any(Runnable.class)); + var parameter = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (((Integer) call.getArgument(0)) == 1) parameter.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(parameter.get()); return 1; }); + var data = new com.bencodez.advancedcore.api.user.UserData(user) { + @Override public void setValues(java.util.HashMap values) { + setInt(nestedKey, 7, false, true); super.setValues(values); + } + }; + when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + cache.processChanges(); assertFalse(cache.hasChangesToProcess()); + if (completeFirst) { assertEquals(7, stored.get()); assertTrue(tasks.isEmpty()); } + else { + assertEquals(20, stored.get()); assertEquals(1, tasks.size()); + if (replaceProvider) when(f.plugin.getMysql()).thenReturn(replacement.table); + if (failDeferred) doThrow(new SQLException("synthetic deferred failure")).when(f.statement).executeUpdate(); + tasks.remove(0).run(); + if (failDeferred) { assertEquals(20, stored.get()); assertTrue(cache.hasChangesToProcess()); } + else assertEquals(7, stored.get()); + if (replaceProvider) verify(replacement.connection, never()).prepareStatement(anyString()); + } + assertEquals(7, cache.getIfPresent("X").getInt()); + } finally { while (!tasks.isEmpty()) tasks.remove(0).run(); executor.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void nativeMysqlIndependentWriteAfterNestedConfirmationSurvivesBatch() throws Exception { nativeMysqlIndependentAfterNested("X"); } + @Test void nativeMysqlIndependentWriteAfterNestedAliasSurvivesBatch() throws Exception { nativeMysqlIndependentAfterNested("x"); } + private void nativeMysqlIndependentAfterNested(String nestedKey) throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); + when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(f.plugin.getMysql()).thenReturn(f.table); doNothing().when(f.table).checkColumn(anyString(), any()); + var parameter = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (((Integer) call.getArgument(0)) == 1) parameter.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(parameter.get()); return 1; }); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + var data = new com.bencodez.advancedcore.api.user.UserData(user) { + @Override public void setValues(java.util.HashMap values) { + setInt(nestedKey, 7, false); + if (first.compareAndSet(true, false)) { + entered.countDown(); + try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + } + super.setValues(values); + } + }; + when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + var flush = worker.submit(cache::processChanges); assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + new com.bencodez.advancedcore.api.user.UserData(user).setInt("X", 99, false); release.countDown(); + flush.get(5, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(99, stored.get()); assertEquals(99, cache.getIfPresent("X").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void nativeMysqlFailedNestedSameKeyCannotAcknowledgeOlderBatch() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); + when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(f.plugin.getMysql()).thenReturn(f.table); doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenThrow(new SQLException("synthetic nested write failure")); + var data = new com.bencodez.advancedcore.api.user.UserData(user) { + @Override public void setValues(java.util.HashMap values) { + setInt("X", 7, false); super.setValues(values); + } + }; + when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new com.bencodez.simpleapi.sql.data.DataValueInt(10)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + assertThrows(IllegalStateException.class, cache::processChanges); + assertTrue(cache.hasChangesToProcess()); + var queries = org.mockito.ArgumentCaptor.forClass(String.class); + verify(f.connection, atLeastOnce()).prepareStatement(queries.capture()); + assertTrue(queries.getAllValues().stream().allMatch(query -> query.contains("X"))); + doReturn(1).when(f.statement).executeUpdate(); + cache.processChanges(); + assertFalse(cache.hasChangesToProcess()); assertEquals(7, cache.getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void nativeMysqlConfirmedNestedSameKeyDoesNotReplayAndFlushesOtherKeys() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); + when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(f.plugin.getMysql()).thenReturn(f.table); doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenReturn(1); + var data = new com.bencodez.advancedcore.api.user.UserData(user) { + @Override public void setValues(java.util.HashMap values) { + setInt("X", 7, false); super.setValues(values); + } + }; + when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new com.bencodez.simpleapi.sql.data.DataValueInt(10)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Z", 30), true); + cache.processChanges(); + var queries = org.mockito.ArgumentCaptor.forClass(String.class); + verify(f.connection, times(2)).prepareStatement(queries.capture()); + assertTrue(queries.getAllValues().get(0).contains("X")); + assertTrue(queries.getAllValues().get(1).contains("Z")); + assertFalse(queries.getAllValues().get(1).contains("X")); + assertEquals(7, cache.getIfPresent("X").getInt()); assertEquals(30, cache.getIfPresent("Z").getInt()); + assertFalse(cache.hasChangesToProcess()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlOrderedBulkAliasesReconcileLastAssignment() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(ID, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); manager.getUserDataCache().put(ID, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var lastValue = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { Object argument = call.getArgument(1); if (argument instanceof Number number) lastValue.set(number.intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(lastValue.get()); return 1; }); + try { + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Activations", 2), true); + var values = new java.util.LinkedHashMap(); + values.put("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); values.put("activations", new com.bencodez.simpleapi.sql.data.DataValueInt(20)); + data.setValues(values); assertEquals(20, cache.getIfPresent("Activations").getInt()); cache.processChanges(); assertEquals(20, stored.get()); + values.clear(); values.put("activations", new com.bencodez.simpleapi.sql.data.DataValueInt(30)); values.put("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(40)); + data.setValues(values); assertEquals(40, cache.getIfPresent("Activations").getInt()); assertEquals(40, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlScalarAfterBulkOverrideSurvivesOlderQueuedWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(ID, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); manager.getUserDataCache().put(ID, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + try { + data.setInt("Activations", 2, true); var values = new java.util.HashMap(); + values.put("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); data.setValues(values); data.setInt("Activations", 20, false); + assertEquals(20, stored.get()); cache.processChanges(); assertEquals(20, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void snapshotReplacementPreservesNewerQueuedMysqlWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(ID, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); manager.getUserDataCache().put(ID, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + try { + var values = new java.util.HashMap(); values.put("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(5)); + data.setValues(values); data.setInt("Activations", 10, true); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + assertEquals(10, cache.getIfPresent("Activations").getInt()); cache.processChanges(); assertEquals(10, stored.get()); + data.setValues(values); data.setInt("Activations", 12, true); + data.setTempCache(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + data.updateCacheWithTemp(); assertEquals(12, cache.getIfPresent("Activations").getInt()); cache.processChanges(); assertEquals(12, stored.get()); + data.setInt("Activations", 20, true); values.put("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(15)); data.setValues(values); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + assertEquals(15, cache.getIfPresent("Activations").getInt()); cache.processChanges(); assertEquals(15, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlAliasesRemainUsableAfterQueuedFlush() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(ID, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); when(f.statement.executeUpdate()).thenReturn(1); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); manager.getUserDataCache().put(ID, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + try { + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("activations", 10), true); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + assertEquals(10, cache.getIfPresent("Activations").getInt()); cache.processChanges(); + assertDoesNotThrow(() -> data.setInt("activations", 11, false)); + assertEquals(11, cache.getIfPresent("Activations").getInt()); + assertDoesNotThrow(() -> data.setInt("Activations", 12, true)); cache.processChanges(); + assertEquals(12, cache.getIfPresent("activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlAliasDirectWriteSupersedesOlderQueuedArithmetic() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); manager.getUserDataCache().put(ID, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + try { + assertTrue(manager.tryMutateLegacyIntegers(ID, java.util.Map.of("Activations", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + data.setInt("activations", 10, false); cache.processChanges(); + assertEquals(10, stored.get()); assertEquals(10, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlAliasBulkWriteSupersedesOlderQueuedArithmetic() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); manager.getUserDataCache().put(ID, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + try { + assertTrue(manager.tryMutateLegacyIntegers(ID, java.util.Map.of("Activations", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + var values = new java.util.HashMap(); values.put("activations", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); + data.setValues(values); cache.processChanges(); + assertEquals(10, stored.get()); assertEquals(10, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyMysqlArithmeticUsesExistingColumnAlias() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(5)))); manager.getUserDataCache().put(ID, cache); + try { + var result = manager.tryMutateLegacyIntegers(ID, java.util.Map.of("activations", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + assertNotNull(result); assertEquals(5, result.previous().get("activations")); assertEquals(6, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyPostgresArithmeticKeepsDistinctCaseColumns() throws Exception { + Fixture f = new Fixture(DbType.POSTGRESQL); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(5)))); manager.getUserDataCache().put(ID, cache); + try { + var result = manager.tryMutateLegacyIntegers(ID, java.util.Map.of("activations", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + assertNotNull(result); assertEquals(0, result.previous().get("activations")); assertEquals(5, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void newerMysqlDirectWriteSupersedesDelayedOlderWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + data.setInt("Activations", 2, false, true); data.setInt("Activations", 10, false); task.get().run(); + assertEquals(10, stored.get()); + manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void newerMysqlColumnAliasSupersedesDelayedOlderWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + data.setInt("activations", 2, false, true); data.setInt("Activations", 10, false); task.get().run(); + assertEquals(10, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlFailureCanRecoverThroughColumnAliasRetry() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); when(f.statement.executeUpdate()).thenThrow(new SQLException("synthetic failure")).thenReturn(1); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + data.setInt("activations", 4, false); data.setInt("Activations", 4, false); + manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void postgresDistinctCaseColumnsAreNotCoalesced() throws Exception { + Fixture f = new Fixture(DbType.POSTGRESQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var sql = new java.util.concurrent.atomic.AtomicReference(); var value = new java.util.concurrent.atomic.AtomicInteger(); + var lower = new java.util.concurrent.atomic.AtomicInteger(); var upper = new java.util.concurrent.atomic.AtomicInteger(); + when(f.connection.prepareStatement(anyString())).thenAnswer(call -> { sql.set(call.getArgument(0)); return f.statement; }); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { if (sql.get().contains("\"activations\"")) lower.set(value.get()); else upper.set(value.get()); return 1; }); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + data.setInt("activations", 2, false, true); data.setInt("Activations", 10, false); task.get().run(); + assertEquals(2, lower.get()); assertEquals(10, upper.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlCallbackCheckpointAliasSupersedesOuterDirectWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); var called = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); return 1; }); + doAnswer(call -> { if (called.compareAndSet(false, true)) { cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Activations", 20), true); cache.flushChangesAndRun(() -> {}); } return null; }).when(users).onChange(eq(user), eq("activations")); + try { data.setInt("activations", 10, false); assertEquals(20, stored.get()); } + finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlFirstRowCallbackCheckpointSupersedesOuterDirectWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); f.uuids.remove(ID.toString()); + doReturn(f.uuids).when(f.table).getUuids(); doReturn(false).when(f.table).containsKeyQuery(anyString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(); var called = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> { Object v = call.getArgument(1); if (v instanceof Number n) value.set(n.intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(value.get()); f.uuids.add(ID.toString()); return 1; }); + doAnswer(call -> { if (called.compareAndSet(false, true)) { cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Activations", 20), true); cache.flushChangesAndRun(() -> {}); } return null; }).when(users).onChange(eq(user), eq("activations")); + try (var lookup = mockStatic(UuidLookup.class)) { + var resolver = mock(UuidLookup.class); lookup.when(UuidLookup::getInstance).thenReturn(resolver); + when(resolver.getPlayerName(any(), anyString(), eq(false))).thenReturn("Player"); + data.setInt("activations", 10, false); assertEquals(20, stored.get()); + } + finally { manager.getTimer().shutdownNow(); } + } + @Test void olderMysqlBulkSkipsSupersededKeyButWritesOtherKey() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); when(f.statement.executeUpdate()).thenReturn(1); + var old = manager.beginLegacyDirectWrite(ID, Set.of("MYSQL:Activations", "MYSQL:Balance")); + try { + new com.bencodez.advancedcore.api.user.UserData(user).setInt("Activations", 10, false); + var columns = new java.util.ArrayList(); + columns.add(new com.bencodez.simpleapi.sql.Column("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(2))); + columns.add(new com.bencodez.simpleapi.sql.Column("Balance", new com.bencodez.simpleapi.sql.data.DataValueInt(3))); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectWrite(manager, old, null, () -> f.table.update(ID.toString(), columns, false)); + verify(f.statement, never()).setObject(eq(1), eq(2)); verify(f.statement).setObject(eq(1), eq(3)); + manager.markLegacyDirectWriteSucceeded(old); + } finally { manager.endLegacyDirectWrite(old); manager.getTimer().shutdownNow(); } + } + @Test void failedNewerMysqlWriteDoesNotSuppressOlderSuccessfulWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var value = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); var calls = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { if (call.getArgument(0) == 1) value.set(((Number) call.getArgument(1)).intValue()); return null; }).when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { if (calls.getAndIncrement() == 0) throw new SQLException("newer failed"); stored.set(value.get()); return 1; }); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + data.setInt("Activations", 2, false, true); data.setInt("Activations", 10, false); task.get().run(); + assertEquals(2, stored.get()); + assertThrows(java.util.concurrent.ExecutionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void zeroRowMysqlDirectUpdateCannotAcknowledgeMissingRow() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + doReturn(false).when(f.table).containsKeyQuery(ID.toString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); when(f.statement.executeUpdate()).thenReturn(0); + when(f.statement.executeQuery()).thenReturn(f.result); when(f.result.next()).thenReturn(false); + try { + new com.bencodez.advancedcore.api.user.UserData(user).setInt("Activations", 4, false); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void zeroChangedRowsForExistingMysqlRowCanSucceed() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); when(f.statement.executeUpdate()).thenReturn(0); + when(f.statement.executeQuery()).thenReturn(f.result); when(f.result.next()).thenReturn(true); + try { + new com.bencodez.advancedcore.api.user.UserData(user).setInt("Activations", 4, false); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void zeroRowMysqlBatchRetainsChangesForMissingTarget() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenReturn(0); when(f.statement.executeQuery()).thenReturn(f.result); when(f.result.next()).thenReturn(false); + try { + assertTrue(manager.tryMutateLegacyIntegers(ID, java.util.Map.of("Activations", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + assertThrows(IllegalStateException.class, cache::processChanges); + assertTrue(cache.hasChangesToProcess()); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlDirectFailureDoesNotAcknowledgeRetirement() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenThrow(new SQLException("synthetic write failure")).thenReturn(1); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + assertDoesNotThrow(() -> data.setInt("Activations", 4, false)); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertDoesNotThrow(() -> data.setInt("Activations", 4, false)); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlMissingRowDirectFailureDoesNotPublishIdentity() throws Exception { missingRowDirectFailure(DbType.MYSQL); } + @Test void postgresMissingRowDirectFailureDoesNotPublishIdentity() throws Exception { missingRowDirectFailure(DbType.POSTGRESQL); } + @Test void mysqlBulkDirectFailureRequiresSuccessfulRetry() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenThrow(new SQLException("synthetic write failure")).thenReturn(1); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + var values = new java.util.HashMap(); + values.put("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(4)); + values.put("Balance", new com.bencodez.simpleapi.sql.data.DataValueInt(7)); + try { + assertDoesNotThrow(() -> data.setValues(values)); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + assertDoesNotThrow(() -> data.setValues(values)); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + private void missingRowDirectFailure(DbType dialect) throws Exception { + Fixture f = new Fixture(dialect); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); f.uuids.clear(); + doReturn(f.uuids).when(f.table).getUuids(); + doReturn(false).when(f.table).containsKeyQuery(anyString()); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(f.plugin); when(user.getUUID()).thenReturn(ID.toString()); + when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(f.plugin.getMysql()).thenReturn(f.table); + doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenThrow(new SQLException("synthetic insert failure")); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + assertDoesNotThrow(() -> data.setInt("Activations", 4, false)); + assertFalse(f.uuids.contains(ID.toString())); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlBatchWriteFailureRetainsCreditButDirectWriteKeepsLegacyFailureBehavior() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); f.set(AbstractSqlTable.class, "updateLock", new Object()); f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); + when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(f.plugin.getMysql()).thenReturn(f.table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); + doNothing().when(f.table).checkColumn(anyString(), any()); + when(f.statement.executeUpdate()).thenThrow(new SQLException("synthetic write failure")); + try { + assertTrue(manager.tryMutateLegacyIntegers(ID, java.util.Map.of("Activations", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + assertThrows(IllegalStateException.class, cache::processChanges); + assertTrue(cache.hasChangesToProcess()); assertEquals(2, cache.getIfPresent("Activations").getInt()); + assertDoesNotThrow(() -> f.table.update(ID.toString(), "Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(4))); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void mysqlExistingRowSkipsAnOlderBatchAfterDirectWrite() throws Exception { + Fixture f = new Fixture(DbType.MYSQL); + f.set(AbstractSqlTable.class, "updateLock", new Object()); + f.connect(); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(f.plugin); + when(f.plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(ID, false)).thenReturn(user); when(user.getPlugin()).thenReturn(f.plugin); + when(user.getUUID()).thenReturn(ID.toString()); when(f.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(f.plugin.getMysql()).thenReturn(f.table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, ID); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(ID, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var calls = new java.util.concurrent.atomic.AtomicInteger(); var parameter = new java.util.concurrent.atomic.AtomicInteger(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { if (calls.getAndIncrement() == 0) { entered.countDown(); + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return null; }) + .when(f.table).checkColumn(anyString(), any()); + doAnswer(call -> { if (((Integer) call.getArgument(0)) == 1) parameter.set(((Number) call.getArgument(1)).intValue()); return null; }) + .when(f.statement).setObject(anyInt(), any()); + when(f.statement.executeUpdate()).thenAnswer(call -> { stored.set(parameter.get()); return 1; }); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + assertTrue(manager.tryMutateLegacyIntegers(ID, java.util.Map.of("Activations", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + var older = worker.submit(cache::processChanges); assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + data.setInt("Activations", 10, false); + assertEquals(10, stored.get()); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(10, stored.get()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void legacyStrictReadDoesNotTreatAQueryFailureAsAnEmptyRow() { + var plugin = mock(AdvancedCorePlugin.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(table.getExactStrict(any())).thenThrow(new IllegalStateException("synthetic read failure")) + .thenReturn(new java.util.ArrayList<>()); + var storage = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage(() -> plugin, ID::toString); + assertThrows(IllegalStateException.class, () -> storage.readRowStrict(UserStorage.SQLITE)); + assertTrue(storage.readRowStrict(UserStorage.SQLITE).isEmpty()); + verify(table, times(2)).getExactStrict(any()); + verify(table, never()).getExact(any()); + } + + @Test void nestedStrictOwnerScopesRestoreAfterFailure() { + var plugin = mock(AdvancedCorePlugin.class); + var outerTable = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var innerTable = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var outer = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, outerTable); + var inner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, innerTable); + when(plugin.getSQLiteUserTable()).thenReturn(innerTable); + when(outerTable.getExactStrict(any())).thenReturn(new java.util.ArrayList<>()); + when(innerTable.getExactStrict(any())).thenReturn(new java.util.ArrayList<>()); + var storage = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage(() -> plugin, ID::toString); + com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage.withStrictOwner(plugin, outer, () -> { + assertThrows(IllegalStateException.class, () -> com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage.withStrictOwner(plugin, inner, () -> { + storage.readRowStrict(UserStorage.SQLITE); throw new IllegalStateException("synthetic failure"); + })); + storage.readRowStrict(UserStorage.SQLITE); return null; + }); + storage.readRowStrict(UserStorage.SQLITE); + verify(outerTable).getExactStrict(any()); verify(innerTable, times(2)).getExactStrict(any()); + } + + @Test void nestedUnboundStrictScopeDoesNotInheritOuterProvider() { + var plugin = mock(AdvancedCorePlugin.class); + var outerTable = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var liveTable = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var outer = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, outerTable); + when(plugin.getSQLiteUserTable()).thenReturn(liveTable); + when(outerTable.getExactStrict(any())).thenReturn(new java.util.ArrayList<>()); + when(liveTable.getExactStrict(any())).thenReturn(new java.util.ArrayList<>()); + var storage = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage(() -> plugin, ID::toString); + com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage.withStrictOwner(plugin, outer, () -> { + com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage.withStrictOwner(plugin, null, () -> { + storage.readRowStrict(UserStorage.SQLITE); return null; + }); + storage.readRowStrict(UserStorage.SQLITE); return null; + }); + verify(liveTable).getExactStrict(any()); verify(outerTable).getExactStrict(any()); + } + + static class DelegatingMysqlReader extends MySQL { + DelegatingMysqlReader() { super(null, null, null); } + @Override public java.util.ArrayList getExact(String uuid) { return super.getExact(uuid); } + } + static class DelegatingMysqlQueryReader extends MySQL { + DelegatingMysqlQueryReader() { super(null, null, null); } + @Override public java.util.ArrayList getExactQuery(com.bencodez.simpleapi.sql.Column column) { return super.getExactQuery(column); } + } + @Test void mysqlDelegatingOverridesKeepStrictFailure() throws Exception { + for (MySQL table : java.util.List.of(mock(DelegatingMysqlReader.class, CALLS_REAL_METHODS), mock(DelegatingMysqlQueryReader.class, CALLS_REAL_METHODS))) { + Fixture f = new Fixture(DbType.MYSQL, table); + doReturn(java.util.List.of("Points")).when(table).getColumns(); + when(f.manager.getConnectionChecked()).thenThrow(new SQLTransientConnectionException("pool exhausted")); + assertThrows(IllegalStateException.class, () -> f.runtime.populate(ID)); + verify(f.cache, never()).completePopulation(any(), any(), any(), anyBoolean()); + assertDoesNotThrow(() -> table.getExact(ID.toString())); + } + } + static class CustomMysqlReader extends MySQL { + CustomMysqlReader() { super(null, null, null); } + @Override public java.util.ArrayList getExact(String uuid) { + return new java.util.ArrayList<>(java.util.List.of(new com.bencodez.simpleapi.sql.Column("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(17)))); + } + } + static class CustomMysqlQueryReader extends MySQL { + CustomMysqlQueryReader() { super(null, null, null); } + @Override public java.util.ArrayList getExactQuery(com.bencodez.simpleapi.sql.Column column) { + return new java.util.ArrayList<>(java.util.List.of(new com.bencodez.simpleapi.sql.Column("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(18)))); + } + } + static class CustomStrictMysqlReader extends MySQL { + CustomStrictMysqlReader() { super(null, null, null); } + @Override public java.util.ArrayList getExactStrict(String uuid) { return new java.util.ArrayList<>(); } + } + @Test void customStrictProviderCannotImplicitlyCertifyAnEmptyRow() { + var plugin = mock(AdvancedCorePlugin.class); + var table = mock(CustomStrictMysqlReader.class, CALLS_REAL_METHODS); + when(plugin.getMysql()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, table, null)); + var storage = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage(() -> plugin, ID::toString); + assertFalse(storage.supportsStrictLegacyRowReads(UserStorage.MYSQL)); + verify(table, never()).getExactStrict(anyString()); + assertNull(storage.readRowStrict(UserStorage.MYSQL)); + } + @Test void customLegacyReaderIsNotCertifiedAsACompleteRow() { + var plugin = mock(AdvancedCorePlugin.class); + var table = mock(CustomMysqlReader.class, CALLS_REAL_METHODS); + when(plugin.getMysql()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, table, null)); + var storage = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage(() -> plugin, ID::toString); + assertFalse(storage.supportsStrictLegacyRowReads(UserStorage.MYSQL)); + verify(table, never()).getExact(anyString()); + assertNull(storage.readRowStrict(UserStorage.MYSQL)); + assertEquals(17, storage.readRow(UserStorage.MYSQL).get(0).getValue().getInt()); + } + @Test void unavailableNativeReaderCapabilityIsFailureRatherThanUnsupported() { + var plugin = mock(AdvancedCorePlugin.class); + var storage = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage(() -> plugin, ID::toString); + assertThrows(IllegalStateException.class, () -> storage.supportsStrictLegacyRowReads(UserStorage.MYSQL)); + verify(plugin, never()).getMysql(); + verify(plugin, never()).getSQLiteUserTable(); + } + @Test void mysqlSharedReadHonorsExistingReaderOverrides() { + var plugin = mock(AdvancedCorePlugin.class); + var direct = mock(CustomMysqlReader.class, CALLS_REAL_METHODS); + var query = mock(CustomMysqlQueryReader.class, CALLS_REAL_METHODS); + assertEquals(17, new BukkitSqlUserBackend(plugin, UserStorage.MYSQL, direct, null).user(ID).readRow(UserStorage.MYSQL).get(0).getValue().getInt()); + assertEquals(18, new BukkitSqlUserBackend(plugin, UserStorage.MYSQL, query, null).user(ID).readRow(UserStorage.MYSQL).get(0).getValue().getInt()); + } + @Test void sqliteSharedReadHonorsExistingTableReaderOverride() { + var table = new com.bencodez.advancedcore.api.user.userstorage.sql.UserTable(null, "Users", java.util.List.of(new com.bencodez.simpleapi.sql.Column("uuid", new com.bencodez.simpleapi.sql.data.DataValueString("id")))) { + @Override public java.util.ArrayList getExact(com.bencodez.simpleapi.sql.Column column) { + return new java.util.ArrayList<>(java.util.List.of(new com.bencodez.simpleapi.sql.Column("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(17)))); + } + }; + var backend = new BukkitSqlUserBackend(mock(AdvancedCorePlugin.class), UserStorage.SQLITE, null, table); + assertEquals(17, backend.user(ID).readRow(UserStorage.SQLITE).get(0).getValue().getInt()); + } private static final UUID ID = UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"); + @Test void completenessSignalPreservesLegacyOwnerImplementation() { + var cache = mock(UserCacheOwner.class, CALLS_REAL_METHODS); + var values = new java.util.HashMap(); + doReturn(values).when(cache).completePopulation(eq(ID), same(values), isNull()); + assertSame(values, cache.completePopulation(ID, values, null, true)); + verify(cache).completePopulation(ID, values, null); + } + + @Test void nullProviderRowCannotCertifyCompleteSnapshotButEmptyRowCan() { + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + var user = mock(com.bencodez.advancedcore.core.user.storage.SqlUserStorage.class); + var cache = mock(UserCacheOwner.class); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); when(backend.user(ID)).thenReturn(user); + when(backend.hasAuthoritativeRowReads()).thenReturn(true); + when(backend.isOpen()).thenReturn(true); + when(user.readRow(UserStorage.SQLITE)).thenReturn(null, new java.util.ArrayList<>()); + doAnswer(call -> call.getArgument(1)).when(cache).completePopulation(eq(ID), any(), any(), anyBoolean()); + var runtime = new SharedUserDataRuntime(backend, cache); + assertTrue(runtime.populate(ID).isEmpty()); + verify(cache).completePopulation(eq(ID), argThat(java.util.Map::isEmpty), any(), eq(false)); + assertTrue(runtime.populate(ID).isEmpty()); + verify(cache).completePopulation(eq(ID), argThat(java.util.Map::isEmpty), any(), eq(true)); + } + + @Test void sqliteSharedBackendPropagatesStrictReadFailureAndAllowsRetry() { + var plugin = mock(AdvancedCorePlugin.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(table.getExactStrict(any())).thenThrow(new IllegalStateException("synthetic read failure")) + .thenReturn(new java.util.ArrayList<>()); + var backend = new BukkitSqlUserBackend(plugin, UserStorage.SQLITE, null, table); + var cache = mock(UserCacheOwner.class); + var runtime = new SharedUserDataRuntime(backend, cache); + assertThrows(IllegalStateException.class, () -> runtime.populate(ID)); + verify(cache, never()).completePopulation(any(), any(), any()); + verify(cache, never()).completePopulation(any(), any(), any(), anyBoolean()); + doAnswer(call -> call.getArgument(1)).when(cache).completePopulation(eq(ID), any(), any(), eq(true)); + assertTrue(runtime.populate(ID).isEmpty()); + verify(cache).completePopulation(eq(ID), argThat(java.util.Map::isEmpty), any(), eq(true)); + verify(table, times(2)).getExactStrict(any()); + verify(table, never()).getExact(any()); + } + @Test void failedReadCannotPublishSnapshotAndRetryCanReadEmptyRowMysql() throws Exception { failedReadCannotPublishSnapshotAndRetryCanReadEmptyRow(DbType.MYSQL); } @Test void failedReadCannotPublishSnapshotAndRetryCanReadEmptyRowPostgres() throws Exception { failedReadCannotPublishSnapshotAndRetryCanReadEmptyRow(DbType.POSTGRESQL); } @@ -35,13 +861,14 @@ private void failedReadCannotPublishSnapshotAndRetryCanReadEmptyRow(DbType diale when(f.manager.getConnectionChecked()).thenThrow(failure); assertSame(failure, assertThrows(IllegalStateException.class, () -> f.runtime.populate(ID)).getCause()); verify(f.cache, never()).completePopulation(any(), any(), any()); + verify(f.cache, never()).completePopulation(any(), any(), any(), anyBoolean()); verify(f.cache, never()).populate(any(), any()); f.connect(); when(f.statement.executeQuery()).thenReturn(f.result); when(f.result.next()).thenReturn(false); - doAnswer(call -> call.getArgument(1)).when(f.cache).completePopulation(eq(ID), any(), any()); + doAnswer(call -> call.getArgument(1)).when(f.cache).completePopulation(eq(ID), any(), any(), eq(true)); assertTrue(f.runtime.populate(ID).isEmpty()); - verify(f.cache).completePopulation(eq(ID), argThat(java.util.Map::isEmpty), any()); + verify(f.cache).completePopulation(eq(ID), argThat(java.util.Map::isEmpty), any(), eq(true)); verify(f.connection).close(); verify(f.statement).close(); verify(f.result).close(); @@ -90,6 +917,7 @@ private void queryFailuresAlsoAbortReadAndDelete(DbType dialect) throws Exceptio assertSame(failure, assertThrows(IllegalStateException.class, () -> f.runtime.populate(ID)).getCause()); assertSame(failure, assertThrows(IllegalStateException.class, () -> f.runtime.remove(ID)).getCause()); verify(f.cache, never()).completePopulation(any(), any(), any()); + verify(f.cache, never()).completePopulation(any(), any(), any(), anyBoolean()); verify(f.cache, never()).remove(ID); assertTrue(f.uuids.contains(ID.toString())); assertTrue(f.names.contains("Player")); @@ -111,7 +939,7 @@ private void queryFailuresAlsoAbortReadAndDelete(DbType dialect) throws Exceptio } private static final class Fixture { - final MySQL table = mock(MySQL.class, CALLS_REAL_METHODS); + final MySQL table; final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); final ConnectionManager manager = mock(ConnectionManager.class); final Connection connection = mock(Connection.class); @@ -122,7 +950,9 @@ private static final class Fixture { final Set names = ConcurrentHashMap.newKeySet(); final SharedUserDataRuntime runtime; - Fixture(DbType dialect) throws Exception { + Fixture(DbType dialect) throws Exception { this(dialect, mock(MySQL.class, CALLS_REAL_METHODS)); } + Fixture(DbType dialect, MySQL table) throws Exception { + this.table = table; var wrapper = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); when(wrapper.getConnectionManager()).thenReturn(manager); set(AbstractSqlTable.class, "mysql", wrapper); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java index 84614db682..6c3407d735 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java @@ -55,6 +55,153 @@ class SqliteUserTransactionTest { @TempDir Path tempDir; private static final UserStorage TYPE = UserStorage.SQLITE; + @Test void ambiguousCommittedReplayTransactionRetriesWithoutDuplicateAward() throws Exception { + UUID uuid = UUID.randomUUID(); var actual = backend(); createReceipts(actual.databaseFile()); + var backend = spy(actual); var data = spy(actual.user(uuid)); + doReturn(data).when(backend).user(uuid); + var attempts = new java.util.concurrent.atomic.AtomicInteger(); + var committed = new CountDownLatch(1); var retryEntered = new CountDownLatch(1); var allowRetry = new CountDownLatch(1); + doAnswer(call -> { + int attempt = attempts.getAndIncrement(); + if (attempt == 1) { retryEntered.countDown(); if (!allowRetry.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("retry fixture timed out"); } + Object value = call.callRealMethod(); + if (attempt == 0) { committed.countDown(); throw new IllegalStateException("uncertain after commit"); } return value; + }) + .when(data).transaction(eq(TYPE), anyMap(), any()); + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var owner = mock(UserCacheOwner.class); var runtime = new SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + try { + var initial = Map.of("Points", new DataValueInt(0)); + var receipt = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + if (receipt(scope.connection(), "event-one")) return false; + var row = scope.readRow(); int previous = value(row, "Points"); + scope.writeValues(Map.of("Points", new DataValueInt(previous + 1))); insert(scope.connection(), "event-one"); return true; + }).toCompletableFuture(); + assertTrue(committed.await(5, TimeUnit.SECONDS)); + var next = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + if (receipt(scope.connection(), "event-two")) return false; + var row = scope.readRow(); int previous = value(row, "Points"); + scope.writeValues(Map.of("Points", new DataValueInt(previous + 1))); insert(scope.connection(), "event-two"); return true; + }).toCompletableFuture(); + assertTrue(retryEntered.await(5, TimeUnit.SECONDS)); assertFalse(next.isDone()); allowRetry.countDown(); + assertFalse(receipt.cancel(false)); assertFalse(receipt.get(5, TimeUnit.SECONDS)); + assertTrue(next.get(5, TimeUnit.SECONDS)); assertEquals(3, attempts.get()); + assertEquals(2, value(actual.user(uuid).readRow(TYPE), "Points")); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { allowRetry.countDown(); manager.getTimer().shutdownNow(); if (actual.isOpen()) actual.close(); } + } + @Test void definiteReplayRejectionFinishesAndAllowsTheNextQueuedTransaction() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var owner = mock(UserCacheOwner.class); var runtime = new SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + try { + var initial = Map.of("Points", new DataValueInt(0)); + var rejected = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + throw new SqlUserStorage.ReplayPreconditionException("invalid intent"); + }).toCompletableFuture(); + var next = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + scope.writeValues(Map.of("Points", new DataValueInt(2))); return true; + }).toCompletableFuture(); + var failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> rejected.get(5, TimeUnit.SECONDS)); + assertInstanceOf(SqlUserStorage.ReplayPreconditionException.class, failure.getCause()); + manager.afterSharedMutationFinalization(rejected).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertTrue(next.get(5, TimeUnit.SECONDS)); assertEquals(2, value(backend.user(uuid).readRow(TYPE), "Points")); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void replayFinalizationWaitsForCompletionCallbacksAndAdmissionRelease() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + var entered = new CountDownLatch(1); var continueTransaction = new CountDownLatch(1); + var callbackEntered = new CountDownLatch(1); var continueCallback = new CountDownLatch(1); + try { + var operation = manager.submitSharedReplayTransaction(uuid, TYPE, + Map.of("Points", new DataValueInt(0)), scope -> { + entered.countDown(); + try { assertTrue(continueTransaction.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + return true; + }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + operation.whenComplete((result, error) -> { + callbackEntered.countDown(); + try { assertTrue(continueCallback.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new AssertionError(interrupted); } + }); + var finalized = manager.afterSharedMutationFinalization(operation).toCompletableFuture(); + var otherManager = new UserDataManager(mock(AdvancedCorePlugin.class)); + try { assertThrows(IllegalArgumentException.class, () -> otherManager.afterSharedMutationFinalization(operation)); } + finally { otherManager.getTimer().shutdownNow(); } + continueTransaction.countDown(); assertTrue(callbackEntered.await(5, TimeUnit.SECONDS)); + assertFalse(finalized.isDone()); + assertThrows(IllegalStateException.class, () -> manager.withLegacyIntegerOwnerReplacement(() -> fail("owner changed during callback"))); + continueCallback.countDown(); finalized.get(5, TimeUnit.SECONDS); + assertDoesNotThrow(() -> manager.withLegacyIntegerOwnerReplacement(() -> {})); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { continueTransaction.countDown(); continueCallback.countDown(); manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void saturatedReplayLeasesCanStillAdmitCheckpointCleanup() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); + var manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + try { + for (int i = 0; i < 256; i++) { + String identity = "award-" + i; + manager.acquireSharedReplayLease(uuid, identity); + manager.settleSharedReplayLease(uuid, identity, true, false); + } + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> manager.acquireSharedReplayLease(uuid, "new-award")); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> manager.submitSharedReplayTransaction(uuid, TYPE, + Map.of("Points", new DataValueInt(0)), scope -> null)); + manager.acquireSharedReplayCheckpointLease(uuid, "award-0"); + var cleanup = assertDoesNotThrow(() -> manager.submitSharedReplayCheckpoint(uuid, TYPE, "award-0", + Map.of("Points", new DataValueInt(0)), scope -> null)); + cleanup.toCompletableFuture().get(5, TimeUnit.SECONDS); + manager.settleSharedReplayLease(uuid, "award-0", false, true); + manager.afterSharedMutationFinalization(cleanup).toCompletableFuture().get(5, TimeUnit.SECONDS); + } finally { manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void sharedAwardReceiptFlushesToSqliteBeforeOwnerClose() throws Exception { + UUID uuid = UUID.randomUUID(); var backend = backend(); Path database = backend.databaseFile(); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(TYPE); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(manager); + var runtime = new SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); + try { + backend.user(uuid).writeValues(TYPE, new HashMap<>(Map.of("Points", new DataValueInt(1)))); + owner.populate(uuid, new HashMap<>(Map.of("Points", new DataValueInt(1))), true); + var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertTrue(receipt.get(5, TimeUnit.SECONDS).applied()); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, TimeUnit.SECONDS); + try (Connection connection = DriverManager.getConnection("jdbc:sqlite:" + database); + PreparedStatement statement = connection.prepareStatement("SELECT Points FROM Users WHERE uuid=?")) { + statement.setString(1, uuid.toString()); + try (ResultSet result = statement.executeQuery()) { assertTrue(result.next()); assertEquals(2, result.getInt(1)); } + } + } finally { manager.getTimer().shutdownNow(); if (backend.isOpen()) backend.close(); } + } + @Test void newMissingCachedIntegerSurvivesRealSqliteFlush() { + UUID uuid = UUID.randomUUID(); + try (SqliteUserBackend backend = backend()) { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(UserManager.class); + var manager = mock(UserDataManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(uuid, false)).thenReturn(user); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(mock(java.util.concurrent.ScheduledExecutorService.class)); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, uuid); + cache.configureSharedStorage(values -> backend.user(uuid).writeValues(TYPE, values), Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>()); + assertTrue(cache.seedMissingIntegerFromCompleteSnapshot("Points")); + assertTrue(cache.tryMutateIntegers(java.util.Map.of("Points", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + cache.processChangesForSharedRuntime(); + assertEquals(1, value(backend.user(uuid).readRow(TYPE), "Points")); + } + } + /** * Creates a SQLite backend for testing with Points and Votes columns. * 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 61153cb5a9..c74197fe9c 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 @@ -426,6 +426,21 @@ void deferredReplayRetainsOccurrenceAndCheckpoint() { assertTrue(persisted.get(0).contains("Server%pair%server-a")); } + @Test + void deferredReplayDoesNotExportStaleSelectionFromSeparateCallerPlaceholders() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { persisted.clear(); persisted.addAll(invocation.getArgument(1)); return null; }) + .when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); when(reward.getRewardName()).thenReturn("VoteReward"); + RewardOptions options = new RewardOptions().addPlaceholder("Server", "server-a"); + options.setAsyncReplayState(Reward.replayStateFor(new RewardOptions())); + var raw = new HashMap(); raw.put("__advancedcore_replay_selection_c3RhbGU", "c3RhbGU"); + user.addOfflineRewards(reward, raw, options); + assertEquals(1, persisted.size()); assertFalse(persisted.get(0).contains("__advancedcore_replay_selection_")); + assertTrue(persisted.get(0).contains("Server%pair%server-a")); + } + @Test void deferredReplayAtomicallyReplacesItsExistingOccurrence() { String occurrence = UUID.randomUUID().toString(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java index 0e31e7546b..942f8fffe8 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java @@ -15,6 +15,410 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; class AtomicIntegerMutationTest { + @Test void legacyBatchProviderGuardDoesNotAffectAnotherUser() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + var manager = new UserDataManager(plugin); UUID id = UUID.randomUUID(), other = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(id, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + manager.getUserDataCache().put(id, cache); + doAnswer(call -> { + var write = manager.beginLegacyDirectWrite(id); + try { + assertTrue(UserDataCache.hasLegacyBatchPersistence(id.toString())); + assertFalse(UserDataCache.hasLegacyBatchPersistence(other.toString())); + UserDataCache.runOutsideLegacyBatch(() -> assertFalse(UserDataCache.hasLegacyBatchPersistence(id.toString()))); + assertTrue(UserDataCache.hasLegacyBatchPersistence(id.toString())); + assertDoesNotThrow(() -> UserDataCache.rejectStaleLegacyBatch(other.toString())); + assertDoesNotThrow(() -> UserDataCache.propagateLegacyBatchWriteFailure(other.toString(), new java.sql.SQLException("synthetic"))); + assertThrows(UserDataCache.StaleLegacyBatchException.class, () -> UserDataCache.rejectStaleLegacyBatch(id.toString())); + } finally { manager.endLegacyDirectWrite(write); } + return null; + }).when(data).setValues(any(HashMap.class)); + try { + assertTrue(manager.tryMutateLegacyIntegers(id, Map.of("a", 1), IntegerMutation.ADD, null).applied()); + cache.processChanges(); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void failedLegacyFlushDoesNotReplaceNewerConcurrentMutation() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { entered.countDown(); await(release); throw new IllegalStateException("synthetic failure"); }).when(data).setValues(any(HashMap.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var worker = Executors.newSingleThreadExecutor(); + try { + assertTrue(cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null).applied()); + var flush = worker.submit(cache::processChanges); assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null).applied()); + release.countDown(); assertThrows(ExecutionException.class, () -> flush.get(5, TimeUnit.SECONDS)); + assertEquals(3, cache.getIfPresent("a").getInt()); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void failedLegacyFlushRetainsLaterDirectValueForRetry() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var stored = new AtomicInteger(); var calls = new AtomicInteger(); + doAnswer(call -> { if (calls.getAndIncrement() == 0) throw new IllegalStateException("synthetic failure"); + stored.set(call.>getArgument(0).get("a").getInt()); return null; }).when(data).setValues(any(HashMap.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + assertTrue(cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null).applied()); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 10), false); + assertThrows(IllegalStateException.class, cache::processChanges); + assertEquals(10, cache.getIfPresent("a").getInt()); + cache.processChanges(); + assertEquals(10, stored.get()); + } + @Test void stagedLegacySetterIsFlushedBeforeNextCheckpoint() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var stored = new AtomicInteger(1); + doAnswer(call -> { stored.set(((DataValue) call.>getArgument(0).get("a")).getInt()); return null; }).when(data).setValues(any(HashMap.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); await(release); })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 10), true); + var next = pool.submit(() -> cache.flushChangesAndRun(() -> assertEquals(10, stored.get()))); + release.countDown(); first.get(5, TimeUnit.SECONDS); next.get(5, TimeUnit.SECONDS); + assertEquals(10, stored.get()); + } finally { release.countDown(); pool.shutdownNow(); } + } + @Test void stagedLegacySetterIsRetainedWhenRetirementStartsDuringCheckpoint() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var worker = Executors.newSingleThreadExecutor(); + try { + var checkpoint = worker.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); await(release); })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 10), true); + cache.beginRemoval(); + assertThrows(IllegalStateException.class, () -> cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 11), true)); + release.countDown(); checkpoint.get(5, TimeUnit.SECONDS); + assertEquals(10, cache.getIfPresent("a").getInt()); + assertTrue(cache.hasChangesToProcess()); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void queuedLegacySetterSurvivesCheckpointReplacementAndFailure() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var worker = Executors.newSingleThreadExecutor(); + try { + var checkpoint = worker.submit(() -> cache.flushChangesAndRun(() -> { + entered.countDown(); await(release); + cache.updateCache(new HashMap<>(Map.of("a", new DataValueInt(5)))); + throw new IllegalStateException("synthetic checkpoint failure"); + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 10), true); + assertEquals(10, cache.getIfPresent("a").getInt()); + release.countDown(); + assertInstanceOf(IllegalStateException.class, assertThrows(ExecutionException.class, () -> checkpoint.get(5, TimeUnit.SECONDS)).getCause()); + assertEquals(10, cache.getIfPresent("a").getInt()); + assertTrue(cache.hasChangesToProcess()); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void legacySetterMustNotWaitBehindStorageCheckpoint() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var pool = Executors.newFixedThreadPool(2); + try { + var checkpoint = pool.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); await(release); })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var setter = pool.submit(() -> cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 10), true)); + try { setter.get(1, TimeUnit.SECONDS); } + finally { release.countDown(); } + checkpoint.get(5, TimeUnit.SECONDS); + assertEquals(10, cache.getIfPresent("a").getInt()); + } finally { release.countDown(); pool.shutdownNow(); } + } + @Test void strictLegacyReadPreservesNewerMutationsAndRejectsRetirement() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + long version = cache.getSharedSnapshotVersion(); + assertTrue(cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null).applied()); + assertTrue(cache.tryPublishCompleteLegacySnapshot(new HashMap<>(Map.of("a", new DataValueInt(1))), version)); + assertEquals(2, cache.getIfPresent("a").getInt()); + long retiringVersion = cache.getSharedSnapshotVersion(); + cache.beginRemoval(); + assertFalse(cache.tryPublishCompleteLegacySnapshot(new HashMap<>(), retiringVersion)); + assertEquals(2, cache.getIfPresent("a").getInt()); + } + @Test void refreshStartedAfterInvalidatedFlushUsesFreshStorageValue() throws Exception { + for (String invalidated : new String[] { "a", "other" }) { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.configureSharedStorage(values -> { entered.countDown(); await(release); }, Runnable::run); + cache.updateCache(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var executor = Executors.newSingleThreadExecutor(); + try { + assertTrue(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + var flush = executor.submit(cache::processChangesForSharedRuntime); + assertTrue(entered.await(5, TimeUnit.SECONDS)); cache.invalidateStorageSnapshot(invalidated); + release.countDown(); flush.get(5, TimeUnit.SECONDS); + long version = cache.getSharedSnapshotVersion(); + cache.updateSharedSnapshot(new HashMap<>(Map.of("a", new DataValueInt(9))), version); + assertEquals(9, cache.getIfPresent("a").getInt()); + } finally { release.countDown(); executor.shutdownNow(); } + } + } + @Test void invalidatedInFlightDebitSurvivesAnOlderRefresh() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.configureSharedStorage(values -> { entered.countDown(); await(release); }, Runnable::run); + cache.updateCache(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var executor = Executors.newSingleThreadExecutor(); + try { + assertTrue(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + var flush = executor.submit(cache::processChangesForSharedRuntime); + assertTrue(entered.await(5, TimeUnit.SECONDS)); cache.invalidateStorageSnapshot("a"); + long version = cache.getSharedSnapshotVersion(); + release.countDown(); flush.get(5, TimeUnit.SECONDS); + assertNull(cache.getIfPresent("a")); + cache.updateSharedSnapshot(new HashMap<>(Map.of("a", new DataValueInt(1))), version); + assertEquals(0, cache.getIfPresent("a").getInt()); + assertFalse(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + } finally { release.countDown(); executor.shutdownNow(); } + } + + @Test void arithmeticRetriesAfterCheckpointChangesBaseline() throws Exception { + var cache = cache(); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try { + var checkpoint = executor.submit(() -> cache.flushChangesAndRun(() -> { + entered.countDown(); await(release); + cache.updateCache(new HashMap<>(Map.of("a", new DataValueInt(5)))); + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertNull(cache.tryMutateIntegers(Map.of("a", 1), IntegerMutation.ADD, null)); + release.countDown(); checkpoint.get(5, TimeUnit.SECONDS); + assertTrue(cache.tryMutateIntegers(Map.of("a", 1), IntegerMutation.ADD, null).applied()); + assertEquals(6, cache.getIfPresent("a").getInt()); + } finally { release.countDown(); executor.shutdownNow(); } + } + @Test void legacyClearingDoesNotBlockCachedArithmeticOnStorage() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { entered.countDown(); await(release); return null; }).when(data).setValues(any(HashMap.class)); + var pool = Executors.newFixedThreadPool(2); + try { + var clear = pool.submit(cache::clearCache); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var mutation = pool.submit(() -> cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null)); + assertNull(mutation.get(1, TimeUnit.SECONDS)); + release.countDown(); clear.get(5, TimeUnit.SECONDS); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test void overlappingLegacyFlushesCannotLoseAnInFlightDebit() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1), "b", new DataValueInt(0)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var secondEntered = new CountDownLatch(1); + var writes = new AtomicInteger(); + doAnswer(call -> { if (writes.getAndIncrement() == 0) { entered.countDown(); await(release); } else secondEntered.countDown(); return null; }).when(data).setValues(any(HashMap.class)); + var pool = Executors.newFixedThreadPool(2); + try { + assertTrue(cache.tryMutateLegacyIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + var first = pool.submit(cache::processChanges); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(cache.tryMutateLegacyIntegers(Map.of("b", 1), IntegerMutation.ADD, null).applied()); + var second = pool.submit(cache::processChanges); + assertFalse(secondEntered.await(300, TimeUnit.MILLISECONDS), "second batch must wait for the first"); + cache.updateCache(new HashMap<>(Map.of("a", new DataValueInt(1), "b", new DataValueInt(0)))); + assertFalse(cache.tryMutateLegacyIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + release.countDown(); first.get(5, TimeUnit.SECONDS); second.get(5, TimeUnit.SECONDS); + } finally { release.countDown(); pool.shutdownNow(); } + } + @Test void clearingCompleteSnapshotDoesNotInventAZeroBalance() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(25)))); + cache.clearCache(); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("a")); + assertFalse(cache.hasPublishedStorageSnapshot()); + } + @Test void sharedAwardDoesNotReportFailureAfterSchedulingThrows() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + doThrow(new IllegalStateException("schedule failed")).when(plugin).debug("Schedule changes"); + var written = new AtomicReference>(); + var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(written::set, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1), "b", new DataValueInt(2)))); + var result = cache.tryMutateIntegers(Map.of("a", 1, "b", 1), IntegerMutation.ADD, null); + assertTrue(result.applied()); assertEquals(2, cache.getIfPresent("a").getInt()); + assertEquals(3, cache.getIfPresent("b").getInt()); assertTrue(cache.hasChangesToProcess()); + cache.processChangesForSharedRuntime(); + assertEquals(2, written.get().get("a").getInt()); assertEquals(3, written.get().get("b").getInt()); + } + @Test void sharedAwardDoesNotReportFailureAfterNotificationDispatchThrows() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + doThrow(new IllegalStateException("notification failed")).when(manager).dispatchSharedUserDataNotification(any(Runnable.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + assertTrue(cache.tryMutateIntegers(Map.of("a", 1), IntegerMutation.ADD, keys -> {}).applied()); + assertEquals(2, cache.getIfPresent("a").getInt()); assertTrue(cache.hasChangesToProcess()); + } + @Test void sharedOverflowRejectsTheEntireBatch() { overflowRejectsTheEntireBatch(true); } + @Test void legacyOverflowRejectsTheEntireBatch() { overflowRejectsTheEntireBatch(false); } + private void overflowRejectsTheEntireBatch(boolean shared) { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + if (shared) cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("safe", new DataValueInt(3), "overflow", new DataValueInt(Integer.MAX_VALUE)))); + var operands = new LinkedHashMap(); operands.put("safe", 1); operands.put("overflow", 1); + var notifications = new AtomicInteger(); + assertThrows(ArithmeticException.class, () -> { + if (shared) cache.tryMutateIntegers(operands, IntegerMutation.ADD, keys -> notifications.incrementAndGet()); + else cache.tryMutateLegacyIntegers(operands, IntegerMutation.ADD, keys -> notifications.incrementAndGet()); + }); + assertEquals(3, cache.getIfPresent("safe").getInt()); + assertEquals(Integer.MAX_VALUE, cache.getIfPresent("overflow").getInt()); + assertFalse(cache.hasChangesToProcess()); assertEquals(0, notifications.get()); + } + + @Test void sharedUnderflowFailsAndOrdinaryFloorStillClamps() { underflowAndFloor(true); } + @Test void legacyUnderflowFailsAndOrdinaryFloorStillClamps() { underflowAndFloor(false); } + private void underflowAndFloor(boolean shared) { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + if (shared) cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("low", new DataValueInt(Integer.MIN_VALUE), "normal", new DataValueInt(1)))); + assertThrows(ArithmeticException.class, () -> { + if (shared) cache.tryMutateIntegers(Map.of("low", -1), IntegerMutation.ADD_FLOOR_ZERO, null); + else cache.tryMutateLegacyIntegers(Map.of("low", -1), IntegerMutation.ADD_FLOOR_ZERO, null); + }); + assertEquals(Integer.MIN_VALUE, cache.getIfPresent("low").getInt()); + assertFalse(cache.hasChangesToProcess()); + if (shared) assertTrue(cache.tryMutateIntegers(Map.of("normal", -2), IntegerMutation.ADD_FLOOR_ZERO, null).applied()); + else assertTrue(cache.tryMutateLegacyIntegers(Map.of("normal", -2), IntegerMutation.ADD_FLOOR_ZERO, null).applied()); + assertEquals(0, cache.getIfPresent("normal").getInt()); + } + + @Test void completeRowReadDistinguishesNullFromEmpty() throws Exception { + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var manager = mock(UserDataManager.class); + when(user.getPlugin()).thenReturn(plugin); when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(manager.effectiveStorageType(any())).thenAnswer(call -> call.getArgument(0)); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class, CALLS_REAL_METHODS); + var sql = mock(com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess.class); + var userField = data.getClass().getDeclaredField("user"); userField.setAccessible(true); userField.set(data, user); + var sqlField = data.getClass().getDeclaredField("sqlData"); sqlField.setAccessible(true); sqlField.set(data, sql); + when(sql.readRow(com.bencodez.advancedcore.api.user.UserStorage.MYSQL)).thenReturn(null, new java.util.ArrayList<>()); + assertNull(data.getValuesIfComplete()); assertTrue(data.getValuesIfComplete().isEmpty()); + } + + @Test void legacyCachedArithmeticSerializesConcurrentUpdates() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var pool = Executors.newFixedThreadPool(2); var start = new CountDownLatch(1); + try { + var first = pool.submit(() -> { start.await(); return cache.tryMutateLegacyIntegers(Map.of("a", -1), IntegerMutation.ADD, null); }); + var second = pool.submit(() -> { start.await(); return cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null); }); + start.countDown(); assertNotNull(first.get(5, TimeUnit.SECONDS)); assertNotNull(second.get(5, TimeUnit.SECONDS)); + assertEquals(1, cache.getIfPresent("a").getInt()); + } finally { pool.shutdownNow(); } + } + + @Test void legacyArithmeticNeverWaitsBehindAnExclusiveStorageOperation() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var pool = Executors.newSingleThreadExecutor(); + try { + var holder = pool.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); await(release); })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertNull(cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null)); + assertEquals(1, cache.getIfPresent("a").getInt()); + release.countDown(); holder.get(5, TimeUnit.SECONDS); + assertNotNull(cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, null)); + assertEquals(2, cache.getIfPresent("a").getInt()); + } finally { release.countDown(); pool.shutdownNow(); } + } + + @Test void missingIntegerDefaultsOnlyFromACompleteCurrentRow() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCache(new HashMap<>()); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("dynamic")); + cache.updateCompleteCachePreservingPending(new HashMap<>()); + assertTrue(cache.seedMissingIntegerFromCompleteSnapshot("dynamic")); + assertTrue(cache.tryMutateIntegers(Map.of("dynamic", 1), IntegerMutation.ADD, null).applied()); + assertEquals(1, cache.getIfPresent("dynamic").getInt()); + cache.invalidateStorageSnapshot("other"); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("second")); + cache.updateCachePreservingPending(new HashMap<>()); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("second")); + } + @Test void directSnapshotReplacementCannotAuthorizeASecondInFlightDebit() throws Exception { for (boolean failFirst : new boolean[] { false, true }) { var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); @@ -139,7 +543,7 @@ class AtomicIntegerMutationTest { try { var checkpoint = executor.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); await(release); })); assertTrue(entered.await(5, TimeUnit.SECONDS)); - assertTrue(cache.tryMutateIntegers(Map.of("a", 1), IntegerMutation.ADD, null).applied()); + assertTrue(cache.tryAddChangeBeforeDeferredSharedFlush(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("a", 2))); cache.invalidateStorageSnapshot("a"); assertNull(cache.getIfPresent("a")); release.countDown(); checkpoint.get(5, TimeUnit.SECONDS); assertNull(cache.getIfPresent("a")); assertTrue(cache.hasChangesToProcess()); @@ -212,10 +616,12 @@ class AtomicIntegerMutationTest { replaced.countDown(); await(finish); })); assertTrue(entered.await(5, TimeUnit.SECONDS)); - assertTrue(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + assertNull(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null)); replace.countDown(); assertTrue(replaced.await(5, TimeUnit.SECONDS)); - assertFalse(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + assertNull(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null)); finish.countDown(); checkpoint.get(5, TimeUnit.SECONDS); + assertTrue(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + assertFalse(cache.tryMutateIntegers(Map.of("a", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); assertEquals(0, cache.getIfPresent("a").getInt()); } finally { replace.countDown(); finish.countDown(); executor.shutdownNow(); } } @@ -328,10 +734,13 @@ class AtomicIntegerMutationTest { throw new IllegalStateException("checkpoint failure"); })); assertTrue(entered.await(5, TimeUnit.SECONDS)); - assertTrue(cache.tryMutateIntegers(Map.of("a", -1, "b", -1), IntegerMutation.DEBIT_POSITIVE, - keys -> notices.incrementAndGet()).applied()); + assertNull(cache.tryMutateIntegers(Map.of("a", -1, "b", -1), IntegerMutation.DEBIT_POSITIVE, + keys -> notices.incrementAndGet())); replace.countDown(); assertThrows(ExecutionException.class, () -> checkpoint.get(5, TimeUnit.SECONDS)); + assertEquals(0, notices.get()); + assertTrue(cache.tryMutateIntegers(Map.of("a", -1, "b", -1), IntegerMutation.DEBIT_POSITIVE, + keys -> notices.incrementAndGet()).applied()); assertEquals(0, cache.getIfPresent("a").getInt()); assertEquals(1, cache.getIfPresent("b").getInt()); assertEquals(1, notices.get()); assertTrue(cache.hasChangesToProcess()); @@ -407,7 +816,7 @@ private UserDataCache cache() { }); assertTrue(result.applied()); assertEquals(1,called.get()); assertEquals(3,cache.getIfPresent("b").getInt()); } - @Test void exclusiveFlushStagesOneBatchAndDelaysNotification() throws Exception { + @Test void exclusiveFlushDefersArithmeticUntilAfterCheckpoint() throws Exception { var cache = cache(); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var notices = new AtomicInteger(); var executor = Executors.newSingleThreadExecutor(); try { @@ -419,9 +828,13 @@ private UserDataCache cache() { assertTrue(entered.await(5, TimeUnit.SECONDS)); var result = cache.tryMutateIntegers(Map.of("a",-1,"b",-1),IntegerMutation.DEBIT_POSITIVE, keys -> { assertFalse(Thread.holdsLock(cache)); notices.incrementAndGet(); }); + assertNull(result); assertEquals(1,cache.getIfPresent("a").getInt()); + assertEquals(2,cache.getIfPresent("b").getInt()); assertEquals(0,notices.get()); + release.countDown(); flush.get(5,TimeUnit.SECONDS); + result = cache.tryMutateIntegers(Map.of("a",-1,"b",-1),IntegerMutation.DEBIT_POSITIVE, + keys -> { assertFalse(Thread.holdsLock(cache)); notices.incrementAndGet(); }); assertTrue(result.applied()); assertEquals(0,cache.getIfPresent("a").getInt()); - assertEquals(1,cache.getIfPresent("b").getInt()); assertEquals(0,notices.get()); - release.countDown(); flush.get(5,TimeUnit.SECONDS); assertEquals(1,notices.get()); + assertEquals(1,cache.getIfPresent("b").getInt()); assertEquals(1,notices.get()); } finally { release.countDown(); executor.shutdownNow(); } } @Test void missingOneFieldDoesNotMutateAnyField() { 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 index c6ce44de9f..e706785ac5 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java @@ -44,11 +44,2067 @@ @Timeout(15) class SharedCacheBindingRegressionTest { + @Test void mariaNativeScalarRejectsOlderAliasAfterNewerCommit() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.MARIADB, false, 10); } + @Test void mariaNativeBulkRejectsOlderAliasAfterNewerCommit() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.MARIADB, true, 10); } + @Test void postgresNativeScalarKeepsDistinctAliases() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.POSTGRESQL, false, 2); } + @Test void postgresNativeBulkKeepsDistinctAliases() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.POSTGRESQL, true, 2); } + @Test void unknownDialectDoesNotFoldAliases() throws Exception { nativeAliasOrdering(null, false, 2); } + private void nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType dialect, boolean bulk, int expected) throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class, CALLS_REAL_METHODS); + Field type = com.bencodez.simpleapi.sql.mysql.AbstractSqlTable.class.getDeclaredField("dbType"); type.setAccessible(true); type.set(table, dialect); + Field lock = com.bencodez.simpleapi.sql.mysql.AbstractSqlTable.class.getDeclaredField("updateLock"); lock.setAccessible(true); lock.set(table, new Object()); + Field owner = table.getClass().getDeclaredField("plugin"); owner.setAccessible(true); owner.set(table, plugin); + doNothing().when(table).checkColumn(anyString(), any()); + doReturn(Set.of()).when(table).getUuids(); doReturn(false).when(table).containsKeyQuery(anyString()); + var value = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { value.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).insert(anyString(), anyString(), any(DataValue.class)); + doAnswer(call -> { value.set(call.>getArgument(1).get(0).getValue().getInt()); return null; }).when(table).insertQuery(anyString(), anyList()); + var older = manager.beginLegacyDirectWrite(uuid, "MYSQL:points"); var newer = manager.beginLegacyDirectWrite(uuid, "MYSQL:Points"); + try { + UserDataCache.runWithLegacyDirectWrite(manager, newer, null, () -> table.update(uuid.toString(), "Points", new DataValueInt(10))); + UserDataCache.runWithLegacyDirectWrite(manager, older, null, () -> { + if (bulk) table.update(uuid.toString(), List.of(new Column("points", new DataValueInt(2))), false); + else table.update(uuid.toString(), "points", new DataValueInt(2)); + }); + assertEquals(expected, value.get()); + } finally { manager.endLegacyDirectWrite(newer); manager.endLegacyDirectWrite(older); manager.getTimer().shutdownNow(); } + } + @Test void confirmedNestedSameKeyWriteCanDrainWithoutReplayingOlderValue() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + var stored = new HashMap(); var scalarWrites = new java.util.concurrent.atomic.AtomicInteger(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(id, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id.toString()); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { String key = call.getArgument(1); stored.put(key, ((DataValue) call.getArgument(2)).getInt()); + scalarWrites.incrementAndGet(); UserDataCache.recordLegacyProviderCommit(id.toString(), List.of(key), UserStorage.MYSQL, true); return null; }) + .when(table).update(eq(id.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { var admitted = UserDataCache.filterConfirmedLegacyBatchColumns(id.toString(), call.getArgument(1)); + UserDataCache.rejectStaleLegacyBatch(id.toString()); + for (Column column : admitted) stored.put(column.getName(), column.getValue().getInt()); return null; }) + .when(table).update(eq(id.toString()), anyList(), eq(false)); + var data = new UserData(user) { + @Override public void setValues(HashMap values) { setInt("X", 7, false); super.setValues(values); } + }; + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new UserDataChangeInt("X", 20), true); + cache.addChange(new UserDataChangeInt("Z", 30), true); + cache.processChanges(); + assertEquals(7, stored.get("X")); assertEquals(7, cache.getIfPresent("X").getInt()); + assertEquals(30, stored.get("Z")); assertEquals(30, cache.getIfPresent("Z").getInt()); + assertEquals(1, scalarWrites.get()); assertFalse(cache.hasChangesToProcess()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyIntegerMutationRejectsRetainedCacheFromReplacedProvider() { + var plugin = mock(AdvancedCorePlugin.class); + var original = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var replacement = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var oldOwner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, original); + var newOwner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, replacement); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(plugin.getNativeUserStorageOwner()).thenReturn(oldOwner); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(plugin.getNativeUserStorageOwner()).thenReturn(newOwner); + try { + assertNull(manager.tryMutateLegacyIntegers(id, Map.of("X", 1), UserDataCache.IntegerMutation.ADD, null)); + assertEquals(10, cache.getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyFlushRejectsRetainedCacheFromReplacedProvider() { + var plugin = mock(AdvancedCorePlugin.class); + var original = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var replacement = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var oldOwner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, original); + var newOwner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, replacement); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(plugin.getNativeUserStorageOwner()).thenReturn(oldOwner); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); cache.addChange(new UserDataChangeInt("X", 11), true); + when(plugin.getNativeUserStorageOwner()).thenReturn(newOwner); + try { + assertThrows(IllegalStateException.class, cache::processChanges); + assertTrue(cache.hasChangesToProcess()); verify(replacement, never()).update(any(Column.class), anyList()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyReadUsesCapturedNativeProviderAcrossTemporaryReplacement() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var original = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var replacement = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var owner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, original); + var live = new java.util.concurrent.atomic.AtomicReference<>(original); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(owner); + when(plugin.getSQLiteUserTable()).thenAnswer(call -> live.get()); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id.toString()); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + when(users.getUser(id, false)).thenAnswer(call -> { live.set(replacement); return user; }); + when(original.getExactStrict(any())).thenAnswer(call -> { live.set(original); return new ArrayList<>(List.of(new Column("X", new DataValueInt(10)))); }); + when(replacement.getExactStrict(any())).thenAnswer(call -> { live.set(original); return new ArrayList<>(List.of(new Column("X", new DataValueInt(100)))); }); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + try { + assertTrue(manager.populateCompleteLegacyCache(id)); + assertEquals(10, cache.getIfPresent("X").getInt()); + verify(original).getExactStrict(any()); verify(replacement, never()).getExactStrict(any()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadAcceptsMatchingNativeProvider() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var owner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(owner); when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(id, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("X", new DataValueInt(12)))); + try { + assertTrue(manager.populateCompleteLegacyCache(id)); + assertEquals(12, manager.getPublishedCache(id).getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyMysqlDefaultsUseCapturedProviderRules() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID id = UUID.randomUUID(); + var original = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var replacement = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var live = new java.util.concurrent.atomic.AtomicReference<>(original); + var owner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, original, null); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getNativeUserStorageOwner()).thenReturn(owner); + when(plugin.getMysql()).thenAnswer(call -> live.get()); when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(id, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + when(data.getValuesIfStrictComplete()).thenAnswer(call -> { + live.set(replacement); return new HashMap<>(Map.of("activations", new DataValueInt(7))); + }); + when(original.caseInsensitiveColumnNames()).thenAnswer(call -> { live.set(original); return true; }); + when(replacement.caseInsensitiveColumnNames()).thenAnswer(call -> { live.set(original); return false; }); + var manager = new UserDataManager(plugin); + manager.addKey(new com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt("Activations")); + try { + assertTrue(manager.populateCompleteLegacyCache(id)); + var snapshot = manager.getPublishedCache(id).snapshotIfPublished(); + assertEquals(7, snapshot.get("activations").getInt()); + assertFalse(snapshot.containsKey("Activations"), "a case alias must not invent a zero balance"); + verify(replacement, never()).caseInsensitiveColumnNames(); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadRejectsProviderFieldChangedBeforeOwnerPublication() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); + var oldTable = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var newTable = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var owner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, oldTable); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(owner); + when(plugin.getSQLiteUserTable()).thenReturn(oldTable); + when(plugin.getUserManager()).thenReturn(users); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(users.getUser(id, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("X", new DataValueInt(100)))); + when(plugin.getSQLiteUserTable()).thenReturn(newTable); + try { + assertFalse(manager.populateCompleteLegacyCache(id)); + assertEquals(10, cache.getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void asyncNestedWriteCannotHideAnIndependentWriteBeforeBatchAdmission() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + var stored = new ConcurrentHashMap(); var tasks = new ArrayList(); + var dataRef = new java.util.concurrent.atomic.AtomicReference(); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + var worker = Executors.newSingleThreadExecutor(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getTimer()).thenReturn(timer); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(id, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id.toString()); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + doAnswer(call -> { stored.put(call.getArgument(1), ((DataValue) call.getArgument(2)).getInt()); return null; }) + .when(table).update(eq(id.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { + if (first.compareAndSet(true, false)) worker.submit(() -> dataRef.get().setInt("X", 99, false)).get(5, TimeUnit.SECONDS); + UserDataCache.rejectStaleLegacyBatch(id.toString()); + for (Column column : call.>getArgument(1)) stored.put(column.getName(), column.getValue().getInt()); return null; + }).when(table).update(eq(id.toString()), anyList(), eq(false)); + var data = new UserData(user) { + @Override public void setValues(HashMap values) { setInt("Y", 7, false, true); super.setValues(values); } + }; + dataRef.set(data); when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new UserDataChangeInt("X", 20), true); + assertThrows(IllegalStateException.class, cache::processChanges); + assertEquals(99, stored.get("X")); assertTrue(cache.hasChangesToProcess()); + } finally { for (Runnable task : tasks) task.run(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void asyncDisjointNestedWriteDoesNotPreventEnclosingBatchFromDraining() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + var stored = new ConcurrentHashMap(); + var task = new java.util.concurrent.atomic.AtomicReference(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getTimer()).thenReturn(timer); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(id, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id.toString()); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + doAnswer(call -> { stored.put(call.getArgument(1), ((DataValue) call.getArgument(2)).getInt()); return null; }) + .when(table).update(eq(id.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { UserDataCache.rejectStaleLegacyBatch(id.toString()); + for (Column column : call.>getArgument(1)) stored.put(column.getName(), column.getValue().getInt()); return null; }) + .when(table).update(eq(id.toString()), anyList(), eq(false)); + var data = new UserData(user) { + @Override public void setValues(HashMap values) { setInt("Y", 7, false, true); super.setValues(values); } + }; + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new UserDataChangeInt("X", 20), true); + cache.processChanges(); + assertEquals(20, stored.get("X")); assertFalse(cache.hasChangesToProcess()); + assertNotNull(task.get()); task.get().run(); assertEquals(7, stored.get("Y")); + } finally { if (task.get() != null && !stored.containsKey("Y")) task.get().run(); manager.getTimer().shutdownNow(); } + } + @Test void nestedDisjointWriteCannotAbsorbAnInterveningIndependentWriter() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + var stored = new ConcurrentHashMap(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var blockedOnce = new java.util.concurrent.atomic.AtomicBoolean(); + var rejected = new java.util.concurrent.atomic.AtomicInteger(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(id, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id.toString()); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { + String key = call.getArgument(1); + if (key.equals("Y") && blockedOnce.compareAndSet(false, true)) { + entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + } + stored.put(key, ((DataValue) call.getArgument(2)).getInt()); return null; + }).when(table).update(eq(id.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { + try { UserDataCache.rejectStaleLegacyBatch(id.toString()); } + catch (IllegalStateException stale) { rejected.incrementAndGet(); throw stale; } + for (Column column : call.>getArgument(1)) stored.put(column.getName(), column.getValue().getInt()); return null; + }).when(table).update(eq(id.toString()), anyList(), eq(false)); + var data = new UserData(user) { + @Override public void setValues(HashMap values) { setInt("Y", 7, false); super.setValues(values); } + }; + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var worker = Executors.newSingleThreadExecutor(); + try { + cache.addChange(new UserDataChangeInt("X", 20), true); + var flush = worker.submit(cache::processChanges); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + data.setInt("X", 99, false); + release.countDown(); flush.get(5, TimeUnit.SECONDS); + assertTrue(rejected.get() >= 1, "the intervening write must stale the first batch"); + assertEquals(99, stored.get("X")); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void nestedSameKeyWriteIsNotOverwrittenByAnOlderBatch() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + var stored = new HashMap(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(id, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id.toString()); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { stored.put(call.getArgument(1), ((DataValue) call.getArgument(2)).getInt()); return null; }) + .when(table).update(eq(id.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { UserDataCache.rejectStaleLegacyBatch(id.toString()); + for (Column column : call.>getArgument(1)) stored.put(column.getName(), column.getValue().getInt()); return null; }) + .when(table).update(eq(id.toString()), anyList(), eq(false)); + var data = new UserData(user) { + @Override public void setValues(HashMap values) { setInt("X", 7, false); super.setValues(values); } + }; + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new UserDataChangeInt("X", 20), true); + assertThrows(IllegalStateException.class, cache::processChanges); + assertEquals(7, stored.get("X")); assertTrue(cache.hasChangesToProcess()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void nestedSynchronousDirectWriteDoesNotPermanentlyStaleItsOwnBatch() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + var stored = new HashMap(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(id, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id.toString()); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { stored.put(call.getArgument(1), ((DataValue) call.getArgument(2)).getInt()); return null; }) + .when(table).update(eq(id.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { UserDataCache.rejectStaleLegacyBatch(id.toString()); + for (Column column : call.>getArgument(1)) stored.put(column.getName(), column.getValue().getInt()); return null; }) + .when(table).update(eq(id.toString()), anyList(), eq(false)); + var data = new UserData(user) { + @Override public void setValues(HashMap values) { setInt("Y", 7, false); super.setValues(values); } + }; + when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + cache.addChange(new UserDataChangeInt("X", 20), true); + cache.processChanges(); + assertEquals(20, stored.get("X")); assertEquals(7, stored.get("Y")); + assertFalse(cache.hasChangesToProcess()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadRejectsDifferentStorageFromRetainedCache() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var data = mock(UserData.class); + var manager = new UserDataManager(plugin); + var id = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(users.getUser(id, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("X", new DataValueInt(100)))); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + try { + assertFalse(manager.populateCompleteLegacyCache(id)); + assertEquals(10, cache.getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadRejectsStorageChangeDuringRead() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var data = mock(UserData.class); + var manager = new UserDataManager(plugin); + var id = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(users.getUser(id, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + when(data.getValuesIfStrictComplete()).thenAnswer(call -> { + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + return new HashMap<>(Map.of("X", new DataValueInt(100))); + }); + try { + assertFalse(manager.populateCompleteLegacyCache(id)); + assertEquals(10, cache.getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadRejectsReplacementOfSameStorageProvider() { + var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var data = mock(UserData.class); + var first = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var second = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var original = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, first); + var replacement = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, second); + var manager = new UserDataManager(plugin); var id = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(original); + when(plugin.getUserManager()).thenReturn(users); + var cache = new UserDataCache(manager, id); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(10)))); + manager.getUserDataCache().put(id, cache); + when(users.getUser(id, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("X", new DataValueInt(100)))); + when(plugin.getNativeUserStorageOwner()).thenReturn(replacement); + try { + assertFalse(manager.populateCompleteLegacyCache(id)); + assertEquals(10, cache.getIfPresent("X").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void completedUsersDoNotAccumulateWhileAnotherDirectWriteIsPending() throws Exception { + var manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + var hold = manager.beginLegacyDirectWrite(UUID.randomUUID(), "SQLITE:X"); + try { + for (int i = 0; i < 200; i++) { + var write = manager.beginLegacyDirectWrite(UUID.randomUUID(), "SQLITE:X"); + manager.markLegacyDirectWriteSucceeded(write); manager.endLegacyDirectWrite(write); + } + var field = UserDataManager.class.getDeclaredField("legacyDirectSuccesses"); field.setAccessible(true); + assertTrue(((Map) field.get(manager)).isEmpty(), "completed users must not remain tracked behind an unrelated pending write"); + } finally { manager.endLegacyDirectWrite(hold); manager.getTimer().shutdownNow(); } + } + @Test void uncachedFailedDirectRetrySupersedesEarlierBulkOverride() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + doThrow(new IllegalStateException("failed scalar")).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + when(user.isCached()).thenReturn(false); assertThrows(IllegalStateException.class, () -> data.setInt("Activations", 20, false)); + cache.processChanges(); assertEquals(20, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void uncachedDirectWriteSupersedesBulkOverrideInRetainedCache() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + when(user.isCached()).thenReturn(false); data.setInt("Activations", 20, false); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(21, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void successfulBulkSupersedesStagedChangeAcrossCheckpoint() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var pool = Executors.newSingleThreadExecutor(); + try { + var checkpoint = pool.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); cache.updateCache(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); cache.addChange(new UserDataChangeInt("Activations", 7), true); + data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + release.countDown(); checkpoint.get(5, TimeUnit.SECONDS); + assertEquals(10, cache.getIfPresent("Activations").getInt()); assertTrue(cache.hasChangesToProcess()); + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void failedExternalBulkKeepsEarlierQueuedChange() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenReturn(new ArrayList<>(List.of(new Column("Activations", new DataValueInt(1))))); + doThrow(new IllegalStateException("failed bulk")).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertThrows(IllegalStateException.class, () -> data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10))))); + assertTrue(cache.hasChangesToProcess()); assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(3, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void newerQueuedChangeSurvivesInFlightExternalBulkWrite() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var worker = Executors.newSingleThreadExecutor(); + try { + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + var bulk = worker.submit(() -> data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10))))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); cache.addChange(new UserDataChangeInt("Activations", 20), true); + release.countDown(); bulk.get(5, TimeUnit.SECONDS); assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(21, cache.getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void externalBulkWriteInvalidatesArithmeticUntilFreshRead() { + externalBulkWriteInvalidatesArithmeticUntilFreshRead(false); + } + @Test void externalBulkWriteSupersedesEarlierQueuedArithmetic() { + externalBulkWriteInvalidatesArithmeticUntilFreshRead(true); + } + private void externalBulkWriteInvalidatesArithmeticUntilFreshRead(boolean earlierQueued) { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + if (earlierQueued) assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null)); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, cache.getIfPresent("Activations").getInt()); + cache.processChanges(); assertEquals(11, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void cacheRetirementCannotAcknowledgeADroppedAsyncDirectWrite() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = spy(new UserDataCache(manager, uuid)); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var pending = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + var removalEntered = new CountDownLatch(1); var removalRelease = new CountDownLatch(1); + doAnswer(call -> { removalEntered.countDown(); if (!removalRelease.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return call.callRealMethod(); }).when(cache).beginRemoval(); + try { + data.setInt("Activations", 10, false, true); assertNotNull(pending.get()); + var cancelledObserver = manager.clearCacheAsyncCompletion().toCompletableFuture(); + var retirement = manager.clearCacheAsyncCompletion().toCompletableFuture(); + assertTrue(cancelledObserver.cancel(false)); + assertFalse(retirement.isDone(), "retirement must wait for the accepted write"); + assertThrows(IllegalStateException.class, () -> data.setInt("Activations", 12, false, true)); + var writer = java.util.concurrent.CompletableFuture.runAsync(pending.get()); + assertTrue(removalEntered.await(5, TimeUnit.SECONDS)); + assertDoesNotThrow(() -> writer.get(1, TimeUnit.SECONDS), "writer must not wait for the retirement flush"); + assertFalse(retirement.isDone()); + removalRelease.countDown(); + assertDoesNotThrow(retirement::join); + assertEquals(10, stored.get()); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); throw new AssertionError(interrupted); + } finally { removalRelease.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void failedAcceptedDirectWriteCannotBeAcknowledgedByRetirement() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + doThrow(new IllegalStateException("synthetic write failure")).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var persisted = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) persisted.set(column.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), anyBoolean()); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var pending = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try { + data.setInt("Activations", 10, false, true); + var retirement = manager.clearCacheAsyncCompletion().toCompletableFuture(); + assertThrows(IllegalStateException.class, pending.get()::run); + assertThrows(java.util.concurrent.CompletionException.class, retirement::join, "failed direct write cannot be reported as durable retirement"); + assertSame(cache, manager.getUserDataCache().get(uuid)); + assertTrue(cache.hasChangesToProcess(), "failed accepted write must remain retryable"); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + assertEquals(10, persisted.get(), "retry must persist the accepted cached value"); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void retryOfFailedDirectWriteMustNotOverwriteANewerCachedValue() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + doThrow(new IllegalStateException("synthetic write failure")).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var persisted = new java.util.concurrent.atomic.AtomicInteger(1); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) persisted.set(column.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), anyBoolean()); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var pending = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try { + data.setInt("Activations", 10, false, true); + var retirement = manager.clearCacheAsyncCompletion().toCompletableFuture(); + assertThrows(IllegalStateException.class, pending.get()::run); + assertThrows(java.util.concurrent.CompletionException.class, retirement::join, "failed direct write cannot be reported as durable retirement"); + assertSame(cache, manager.getUserDataCache().get(uuid)); + assertTrue(cache.hasChangesToProcess(), "failed accepted write must remain retryable"); + cache.addChange(new UserDataChangeInt("Activations", 20), true); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + assertEquals(20, persisted.get(), "retry must preserve the newer accepted value"); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void uncachedFailedDirectWriteCannotBeAcknowledgedByRetirement() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var sqlite = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); when(plugin.getSQLiteUserTable()).thenReturn(sqlite); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + doThrow(new IllegalStateException("synthetic write failure")).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(users.getUser(uuid, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); var pending = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try { + data.setInt("Activations", 10, false, true); + assertThrows(IllegalStateException.class, pending.get()::run); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join(), "uncached failed write cannot be acknowledged after its task exits"); + var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + data.setInt("Activations", 11, true, true); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join(), "queued cache update cannot acknowledge the failed direct write"); + data.setInt(UserStorage.SQLITE, "Activations", 10, false, false); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join(), "another storage cannot acknowledge the failed write"); + doNothing().when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + data.setInt("Activations", 10, false, false); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void olderUncachedFailureDoesNotEraseNewerSuccessfulWrite() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { if (((DataValue) call.getArgument(2)).getInt() == 10) throw new IllegalStateException("older write failed"); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); var pending = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try { + data.setInt("Activations", 10, false, true); + data.setInt("Activations", 20, false, false); + assertThrows(IllegalStateException.class, pending.get()::run); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test void olderUncachedSuccessCannotAcknowledgeANewerFailedWrite() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + doAnswer(call -> { if (((DataValue) call.getArgument(2)).getInt() == 20) throw new IllegalStateException("newer write failed"); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); var pending = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try { + data.setInt("Activations", 10, false, true); + assertThrows(IllegalStateException.class, () -> data.setInt("Activations", 20, false, false)); + pending.get().run(); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test void failedUncachedBulkWriteRequiresSuccessfulRetryBeforeRetirement() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + doThrow(new IllegalStateException("synthetic bulk failure")).when(table).update(eq(uuid.toString()), anyList(), anyBoolean()); + var data = new UserData(user); var values = new HashMap(Map.of("Activations", new DataValueInt(10), "Other", new DataValueInt(3))); + try { + assertThrows(IllegalStateException.class, () -> data.setValues(UserStorage.MYSQL, values)); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + doNothing().when(table).update(eq(uuid.toString()), anyList(), anyBoolean()); + data.setValues(UserStorage.MYSQL, new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join(), "partial retry cannot acknowledge another failed key"); + data.setValues(UserStorage.MYSQL, new HashMap<>(Map.of("Other", new DataValueInt(3)))); + assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void asyncDirectSetterKeepsAdmissionUntilExecutionAndReleasesOnRejection() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + var data = new UserData(user); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var task = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + try { + data.setInt("Activations", 10, false, true); + assertNotNull(task.get()); assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null)); + task.get().run(); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, cache.getIfPresent("Activations").getInt()); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic rejection")).when(timer).execute(any(Runnable.class)); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> data.setInt("Activations", 12, false, true)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void rejectedOlderMysqlAliasCannotOverwriteNewerCommittedValue() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + when(table.caseInsensitiveColumnNames()).thenReturn(true); + when(user.isCached()).thenAnswer(call -> { if (first.compareAndSet(true, false)) { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return true; }); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic rejection")).when(timer).execute(any(Runnable.class)); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { for (Column col : call.>getArgument(1)) if (col.getName().equalsIgnoreCase("Activations")) stored.set(col.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); when(user.getCache()).thenReturn(cache); + var worker = Executors.newSingleThreadExecutor(); + try { + var older = worker.submit(() -> assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> data.setInt("activations", 2, false, true))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); data.setInt("Activations", 10, false); release.countDown(); older.get(5, TimeUnit.SECONDS); + assertEquals(10, cache.getIfPresent("Activations").getInt()); cache.processChanges(); assertEquals(10, stored.get()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void rejectedOlderCaseSensitiveKeyRemainsIndependent() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.ConcurrentHashMap(); stored.put("Activations", 1); stored.put("activations", 1); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var first = new java.util.concurrent.atomic.AtomicBoolean(true); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); + when(table.caseInsensitiveColumnNames()).thenReturn(false); + when(user.isCached()).thenAnswer(call -> { if (first.compareAndSet(true, false)) { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return true; }); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic rejection")).when(timer).execute(any(Runnable.class)); + doAnswer(call -> { stored.put(call.getArgument(1), ((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), anyString(), any(DataValue.class)); + doAnswer(call -> { for (Column col : call.>getArgument(1)) stored.put(col.getName(), col.getValue().getInt()); return null; }).when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1), "activations", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); when(user.getCache()).thenReturn(cache); + var worker = Executors.newSingleThreadExecutor(); + try { + var older = worker.submit(() -> assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> data.setInt("activations", 2, false, true))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); data.setInt("Activations", 10, false); release.countDown(); older.get(5, TimeUnit.SECONDS); + cache.processChanges(); assertEquals(10, stored.get("Activations")); assertEquals(2, stored.get("activations")); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void rejectedUncachedOlderMysqlAliasDoesNotBlockRetirement() throws Exception { uncachedAliasFailure(true); } + @Test void rejectedUncachedCaseSensitiveKeyStillBlocksRetirement() throws Exception { uncachedAliasFailure(false); } + private void uncachedAliasFailure(boolean folds) throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var timer = mock(ScheduledExecutorService.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var first = new java.util.concurrent.atomic.AtomicBoolean(true); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(plugin.getTimer()).thenReturn(timer); when(table.caseInsensitiveColumnNames()).thenReturn(folds); + when(user.isCached()).thenAnswer(call -> { if (first.compareAndSet(true, false)) { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return false; }); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic rejection")).when(timer).execute(any(Runnable.class)); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); String key = call.getArgument(1); UserDataCache.recordLegacyProviderCommit(uuid.toString(), List.of(key), UserStorage.MYSQL, folds); return null; }).when(table).update(eq(uuid.toString()), anyString(), any(DataValue.class)); + var data = new UserData(user); var worker = Executors.newSingleThreadExecutor(); + try { + var older = worker.submit(() -> assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> data.setInt("activations", 2, false, true))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); data.setInt("Activations", 10, false); release.countDown(); older.get(5, TimeUnit.SECONDS); + assertEquals(10, stored.get()); + if (folds) assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS)); + else assertThrows(java.util.concurrent.ExecutionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, TimeUnit.SECONDS)); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void completedMysqlAliasReconcilesOlderSnapshot() { mysqlSnapshotAliases(true); } + @Test void foldedMysqlStoredKeyPreventsConflictingRegisteredDefault() { mysqlStoredKeyAndDefault(true); } + @Test void caseSensitiveMysqlStoredKeyRetainsDistinctRegisteredDefault() { mysqlStoredKeyAndDefault(false); } + @Test void mariaDbStoredAliasSupportsWarmLegacyArithmetic() throws Exception { + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + Field type = com.bencodez.simpleapi.sql.mysql.AbstractSqlTable.class.getDeclaredField("dbType"); + type.setAccessible(true); + type.set(table, com.bencodez.simpleapi.sql.mysql.DbType.MARIADB); + doCallRealMethod().when(table).caseInsensitiveColumnNames(); + assertEquals(Boolean.TRUE, table.caseInsensitiveColumnNames()); + mysqlStoredKeyAndDefault(true, table); + type.set(table, com.bencodez.simpleapi.sql.mysql.DbType.POSTGRESQL); + assertEquals(Boolean.FALSE, table.caseInsensitiveColumnNames()); + type.set(table, null); + assertNull(table.caseInsensitiveColumnNames()); + } + private void mysqlStoredKeyAndDefault(boolean folds) { + mysqlStoredKeyAndDefault(folds, null); + } + private void mysqlStoredKeyAndDefault(boolean folds, com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL actualTable) { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = actualTable == null ? mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class) : actualTable; var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); if (actualTable == null) when(table.caseInsensitiveColumnNames()).thenReturn(folds); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); var data = new UserData(user); when(user.getUserData()).thenReturn(data); + when(table.getExactStrict(uuid.toString())).thenReturn(new ArrayList<>(List.of(new Column("activations", new DataValueInt(9))))); + manager.addKey(new com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt("Activations")); + try { + assertTrue(manager.populateCompleteLegacyCache(uuid)); + var result = manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null); + assertNotNull(result); assertTrue(result.applied()); + assertEquals(folds ? 10 : 9, manager.getPublishedCache(uuid).getIfPresent("activations").getInt()); + if (!folds) assertEquals(1, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void completedCaseSensitiveKeyDoesNotRewriteOtherSnapshotKey() { mysqlSnapshotAliases(false); } + private void mysqlSnapshotAliases(boolean folds) { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); when(table.caseInsensitiveColumnNames()).thenReturn(folds); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("X", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + try { + cache.addChange(new UserDataChangeInt("x", 2), true); cache.addChange(new UserDataChangeInt("Other", 3), true); long snapshot = cache.getSharedSnapshotVersion(); + cache.processChanges(); assertTrue(cache.tryPublishCompleteLegacySnapshot(new HashMap<>(Map.of("X", new DataValueInt(1))), snapshot)); + assertEquals(folds ? 2 : 1, cache.getIfPresent("X").getInt()); assertEquals(2, cache.getIfPresent("x").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void directCacheUpdateDoesNotWaitForSlowLegacyCheckpoint() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2); + try { + var checkpoint = pool.submit(() -> cache.flushChangesAndRun(() -> { entered.countDown(); try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); cache.updateCache(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.addChange(new UserDataChangeInt("Activations", 7), true); + var writer = pool.submit(() -> cache.addChange(new UserDataChangeInt("Activations", 10), false)); + assertDoesNotThrow(() -> writer.get(1, TimeUnit.SECONDS), "cache publication must not wait on checkpoint storage"); + assertEquals(10, cache.getIfPresent("Activations").getInt()); + release.countDown(); checkpoint.get(5, TimeUnit.SECONDS); + assertEquals(10, cache.getIfPresent("Activations").getInt(), "earlier staged value cannot overwrite the later direct update"); + assertTrue(cache.hasChangesToProcess()); + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void directCachedSetterCannotLoseAnInterveningCreditFlush() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var stored = new java.util.concurrent.atomic.AtomicInteger(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var cache = spy(new UserDataCache(manager, uuid)); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + doAnswer(call -> { call.callRealMethod(); entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return null; }) + .when(cache).addChange(any(com.bencodez.advancedcore.api.user.usercache.change.UserDataChange.class), eq(false)); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }) + .when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var worker = Executors.newSingleThreadExecutor(); + try { + var setter = worker.submit(() -> data.setInt("Activations", 10, false)); assertTrue(entered.await(5, TimeUnit.SECONDS)); + var during = manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null); + if (during != null) cache.processChanges(); + release.countDown(); setter.get(5, TimeUnit.SECONDS); + if (during == null) { + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + cache.processChanges(); + } + assertEquals(cache.getIfPresent("Activations").getInt(), stored.get()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void directLegacyStringWriteFencesAnOlderStrictRead() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); var reads = new java.util.concurrent.atomic.AtomicInteger(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> { int value = stored.get(); + if (reads.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + return new ArrayList<>(List.of(new Column("Activations", new DataValueInt(value)))); }); + doAnswer(call -> { stored.set(Integer.parseInt(((DataValue) call.getArgument(2)).getString())); return null; }) + .when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + var load = worker.submit(() -> manager.populateCompleteLegacyCache(uuid)); assertTrue(entered.await(5, TimeUnit.SECONDS)); + data.setString("Activations", "10"); + release.countDown(); assertFalse(load.get(5, TimeUnit.SECONDS)); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void legacyMutationNotificationCanReenterManagerForSameUser() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + try { + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, + keys -> assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied())).applied()); + assertEquals(3, cache.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void internalLegacyBatchDoesNotFenceLaterCachedArithmetic() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); var calls = new java.util.concurrent.atomic.AtomicInteger(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { if (calls.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }) + .when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + assertTrue(manager.populateCompleteLegacyCache(uuid)); + UserDataCache cache = manager.getPublishedCache(uuid); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + var flush = worker.submit(cache::processChanges); assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + release.countDown(); flush.get(5, TimeUnit.SECONDS); + cache.processChanges(); assertEquals(3, stored.get()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void externalLegacyBulkWriteFencesAnOlderStrictRead() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> { int value = stored.get(); + if (reads.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + return new ArrayList<>(List.of(new Column("Activations", new DataValueInt(value)))); }); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }) + .when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + var load = worker.submit(() -> manager.populateCompleteLegacyCache(uuid)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + data.setValues(new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + release.countDown(); assertFalse(load.get(5, TimeUnit.SECONDS)); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void ordinaryLegacyPopulationCannotPublishAcrossRetiredDirectWrite() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExact(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + manager.getUserDataCache().put(uuid, new UserDataCache(manager, uuid)); + var setter = worker.submit(() -> data.setInt("Activations", 10)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + manager.removeCache(uuid, null); + manager.cacheUser(uuid, null); + release.countDown(); setter.get(5, TimeUnit.SECONDS); + assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null)); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void directLegacyWriteCannotRetireIntoAStaleReplacement() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + manager.getUserDataCache().put(uuid, new UserDataCache(manager, uuid)); + var setter = worker.submit(() -> data.setInt("Activations", 10)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + manager.removeCache(uuid, null); + boolean populated = manager.populateCompleteLegacyCache(uuid); + release.countDown(); setter.get(5, TimeUnit.SECONDS); + if (!populated) assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadCannotPublishDuringDirectWrite() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + var setter = worker.submit(() -> data.setInt("Activations", 10)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertFalse(manager.populateCompleteLegacyCache(uuid)); + assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null)); + release.countDown(); setter.get(5, TimeUnit.SECONDS); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void concurrentLegacyPopulationsCannotReplaceTheNewerSnapshot() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + UserData data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(data.getValues()).thenAnswer(call -> { + if (calls.getAndIncrement() == 0) { + entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + return new HashMap<>(Map.of("Activations", new DataValueInt(1))); + } + return new HashMap<>(Map.of("Activations", new DataValueInt(8))); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var oldLoad = worker.submit(() -> manager.cacheUser(uuid, null)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + UserDataCache generation = manager.getUserDataCache().get(uuid); + manager.cacheUser(uuid, null); + release.countDown(); oldLoad.get(5, TimeUnit.SECONDS); + assertSame(generation, manager.getUserDataCache().get(uuid)); + assertEquals(8, generation.getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void customLegacyRowReaderRemainsAvailableWithoutClaimingCompleteAuthority() { + var user = mock(AdvancedCoreUser.class); + var data = new UserData(user) { + @Override public List getMySqlRow() { + return List.of(new Column("Activations", new DataValueInt(7))); + } + }; + var plugin = mock(AdvancedCorePlugin.class); + when(user.getPlugin()).thenReturn(plugin); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + assertFalse(data.supportsStrictCompleteValues()); + assertNull(data.getValuesIfStrictComplete()); + assertEquals(7, data.getValues().get("Activations").getInt()); + } + @Test void nativeStrictCapabilityDoesNotReadStorage() { + var user = mock(AdvancedCoreUser.class); var plugin = mock(AdvancedCorePlugin.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(user.getPlugin()).thenReturn(plugin); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + assertTrue(new UserData(user).supportsStrictCompleteValues()); + verifyNoInteractions(table); + verify(plugin, never()).getSQLiteUserTable(); + } + @Test void customReaderMayExplicitlyOptIntoStrictValues() { + var user = mock(AdvancedCoreUser.class); + var data = new UserData(user) { + @Override public boolean supportsStrictCompleteValues() { return true; } + @Override public HashMap getValuesIfStrictComplete() { + return new HashMap<>(Map.of("Activations", new DataValueInt(7))); + } + }; + assertTrue(data.supportsStrictCompleteValues()); + assertEquals(7, data.getValuesIfStrictComplete().get("Activations").getInt()); + } + @Test void legacyPopulationReportsUnsupportedReaderWithoutPublishingPlaceholder() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); UUID uuid = UUID.randomUUID(); + var data = new UserData(user) { @Override public List getMySqlRow() { return List.of(); } }; + when(user.getPlugin()).thenReturn(plugin); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var manager = new UserDataManager(plugin); + try { + assertEquals(UserDataManager.LegacyPopulationResult.UNSUPPORTED, manager.populateCompleteLegacyCacheIfSupported(uuid)); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + verify(plugin, never()).getMysql(); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyPopulationPropagatesSupportedStrictReaderFailure() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); UUID uuid = UUID.randomUUID(); + var data = new UserData(user) { + @Override public boolean supportsStrictCompleteValues() { return true; } + @Override public HashMap getValuesIfStrictComplete() { throw new IllegalStateException("strict read failed"); } + }; + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var manager = new UserDataManager(plugin); + try { assertThrows(IllegalStateException.class, () -> manager.populateCompleteLegacyCacheIfSupported(uuid)); } + finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyPopulationReportsActiveWriterAsRetryWithoutReading() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var admission = manager.beginLegacyDirectWrite(uuid); + try { + assertEquals(UserDataManager.LegacyPopulationResult.RETRY, manager.populateCompleteLegacyCacheIfSupported(uuid)); + verify(users, never()).getUser(eq(uuid), anyBoolean()); + } finally { manager.endLegacyDirectWrite(admission); manager.getTimer().shutdownNow(); } + } + @Test void pendingLegacyAwardRetainsOriginalOperationUntilDirectWriterCompletes() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = mock(AdvancedCoreUser.class); var data = mock(UserData.class); + when(users.getUser(uuid, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + var admission = manager.beginLegacyDirectWrite(uuid); var notices = new java.util.concurrent.atomic.AtomicInteger(); + try { + var completion = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), + UserDataCache.IntegerMutation.ADD, keys -> notices.incrementAndGet()).toCompletableFuture(); + var second = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), + UserDataCache.IntegerMutation.ADD, keys -> notices.incrementAndGet()).toCompletableFuture(); + assertFalse(completion.isDone()); assertEquals(1, cache.getIfPresent("Activations").getInt()); + assertFalse(second.isDone()); + assertThrows(IllegalStateException.class, () -> manager.withLegacyIntegerOwnerReplacement(() -> fail("owner must remain open"))); + assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 9), UserDataCache.IntegerMutation.SET, null), + "a synchronous operation must not overtake the retained operations"); + assertThrows(IllegalStateException.class, manager::clearCache); + assertThrows(IllegalStateException.class, () -> manager.removeCache(uuid, null)); + assertFalse(completion.isDone(), "retirement refusal must retain the admitted operation"); + manager.endLegacyDirectWrite(admission); admission = null; + assertTrue(completion.get(5, TimeUnit.SECONDS).applied()); + assertTrue(second.get(5, TimeUnit.SECONDS).applied()); + assertEquals(3, cache.getIfPresent("Activations").getInt()); assertEquals(2, notices.get()); + } finally { if (admission != null) manager.endLegacyDirectWrite(admission); manager.getTimer().shutdownNow(); } + } + @Test void legacyPlatformMutationDoesNotWaitForContendedStripe() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + var blocked = new java.util.concurrent.locks.ReentrantLock() { + @Override public boolean tryLock() { return false; } + @Override public boolean tryLock(long timeout, TimeUnit unit) { calls.incrementAndGet(); return false; } + }; + var field = UserDataManager.class.getDeclaredField("legacyMutationStripes"); field.setAccessible(true); + var stripes = (java.util.concurrent.locks.ReentrantLock[]) field.get(manager); + stripes[(uuid.hashCode() & Integer.MAX_VALUE) % stripes.length] = blocked; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(org.bukkit.Server.class)); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null)); + assertEquals(0, calls.get(), "platform threads must not wait on the contended stripe"); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void pendingAwardAdmissionRejectsActiveOwnerReplacementWithoutWaiting() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var worker = Executors.newSingleThreadExecutor(); + try { + var replacing = worker.submit(() -> manager.withLegacyIntegerOwnerReplacement(() -> { + entered.countDown(); try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null)); + release.countDown(); replacing.get(5, TimeUnit.SECONDS); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void warmLegacyMutationPinsOwnerUntilItsApplicationCompletes() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, table, null)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + when(table.caseInsensitiveColumnNames()).thenAnswer(call -> { + entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return false; + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var mutation = worker.submit(() -> manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, () -> manager.withLegacyIntegerOwnerReplacement(() -> fail("owner must stay open"))); + release.countDown(); assertTrue(mutation.get(5, TimeUnit.SECONDS).applied()); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void pendingLegacyAwardFailsBeforeMutationWhenOwnerChanges() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var first = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var second = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var firstOwner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, first); + var secondOwner = new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, second); + var owner = new java.util.concurrent.atomic.AtomicReference<>(firstOwner); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenAnswer(call -> owner.get()); + when(plugin.getSQLiteUserTable()).thenAnswer(call -> owner.get().table()); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var completion = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), + UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + owner.set(secondOwner); release.countDown(); + assertThrows(java.util.concurrent.ExecutionException.class, () -> completion.get(5, TimeUnit.SECONDS)); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void pendingLegacyMutationsPreserveAddSetAddOrder() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var stored = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID uuid = UUID.randomUUID(); + when(users.getUser(uuid, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + var manager = new UserDataManager(plugin); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + try { + var first = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var second = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 10), UserDataCache.IntegerMutation.SET, null).toCompletableFuture(); + var third = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 2), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertFalse(first.isDone()); assertFalse(second.isDone()); assertFalse(third.isDone()); + manager.endLegacyDirectWrite(direct); direct = null; + first.get(5, TimeUnit.SECONDS); second.get(5, TimeUnit.SECONDS); third.get(5, TimeUnit.SECONDS); + assertEquals(12, cache.getIfPresent("Activations").getInt()); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void terminalPendingTimeoutDoesNotApplyAfterWriterEnds() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + try { + var pending = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(8, TimeUnit.SECONDS)); + manager.endLegacyDirectWrite(direct); direct = null; + assertEquals(1, cache.getIfPresent("Activations").getInt()); + assertTrue(manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture().isDone()); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void asyncRetirementWaitsForPendingAwardAndItsFlush() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + var direct = manager.beginLegacyDirectWrite(uuid); + try { + var award = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var retirement = manager.clearCacheAsyncCompletion().toCompletableFuture(); + assertFalse(award.isDone()); assertFalse(retirement.isDone()); + manager.endLegacyDirectWrite(direct); direct = null; + assertTrue(award.get(5, TimeUnit.SECONDS).applied()); + retirement.get(5, TimeUnit.SECONDS); + verify(data).setValues(any(HashMap.class)); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void acceptedPendingAwardCannotReportCancellationAndThenApply() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var storedUser = mock(AdvancedCoreUser.class); var storedData = mock(UserData.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + when(users.getUser(uuid, false)).thenReturn(storedUser); when(storedUser.getUserData()).thenReturn(storedData); + when(storedData.supportsStrictCompleteValues()).thenReturn(true); + when(storedData.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + try { + var result = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertFalse(result.cancel(false), "an accepted ticket must remain observable until its actual outcome"); + assertFalse(result.complete(null), "an observer cannot forge a successful result"); + assertFalse(result.completeExceptionally(new IllegalStateException("forged")), "an observer cannot forge failure"); + assertThrows(UnsupportedOperationException.class, () -> result.orTimeout(1, TimeUnit.MILLISECONDS)); + var observer = result.thenApply(value -> value).toCompletableFuture(); + assertTrue(observer.cancel(false)); + assertFalse(result.isDone(), "cancelling a derived observer does not settle the actual receipt"); + manager.endLegacyDirectWrite(direct); direct = null; + assertTrue(result.get(5, TimeUnit.SECONDS).applied()); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void sharedAwardReceiptDrainsBeforeRuntimeOwnerCloses() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner); + manager.bindSharedRuntime(runtime); UUID uuid = UUID.randomUUID(); + var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var award = manager.submitSharedIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertFalse(award.cancel(false)); + assertThrows(IllegalStateException.class, () -> manager.withLegacyIntegerOwnerReplacement(() -> fail("owner must not change"))); + var shutdown = manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture(); + assertFalse(shutdown.isDone()); verify(backend, never()).close(); + release.countDown(); assertTrue(award.get(5, TimeUnit.SECONDS).applied()); + shutdown.get(5, TimeUnit.SECONDS); assertEquals(2, cache.getIfPresent("Activations").getInt()); verify(backend).close(); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void sharedAwardShutdownFlushesToActualBackendBeforeClose() throws Exception { + try (Fixture fixture = new Fixture()) { + var runtime = fixture.runtime(); fixture.manager.bindSharedRuntime(runtime); + var cache = fixture.manager.getCache(fixture.uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); + var receipt = fixture.manager.submitSharedIntegerMutation(fixture.uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var shutdown = fixture.manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture(); + assertFalse(shutdown.isDone()); fixture.tasks.remove(0).run(); + assertTrue(receipt.get(5, TimeUnit.SECONDS).applied()); assertFalse(shutdown.isDone()); + fixture.tasks.remove(fixture.tasks.size() - 1).run(); + assertFalse(shutdown.isDone(), "retirement must wait for the separately scheduled final flush"); + fixture.tasks.remove(fixture.tasks.size() - 1).run(); shutdown.get(5, TimeUnit.SECONDS); + assertEquals(2, fixture.first.rows.get(fixture.uuid).get("Points").getInt()); assertFalse(fixture.first.isOpen()); + } + } + @Test void sharedSynchronousWriterCannotOvertakeAcceptedQueuedAward() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertNull(cache.tryMutateIntegers(Map.of("Points", 2), UserDataCache.IntegerMutation.ADD, null)); + assertEquals(1, cache.getIfPresent("Points").getInt()); + release.countDown(); assertTrue(first.get(5, TimeUnit.SECONDS).applied()); + assertTrue(cache.tryMutateIntegers(Map.of("Points", 2), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(4, cache.getIfPresent("Points").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void sharedAwardWaitsForPriorSynchronousWriterAndKeepsOrder() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var begin = UserDataManager.class.getDeclaredMethod("beginSharedSynchronousIntegerMutation", UUID.class); begin.setAccessible(true); + var end = UserDataManager.class.getDeclaredMethod("endSharedSynchronousIntegerMutation", UUID.class); end.setAccessible(true); + assertEquals(true, begin.invoke(manager, uuid)); + try { + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 5), UserDataCache.IntegerMutation.SET, null).toCompletableFuture(); + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 2), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertFalse(first.isDone()); assertFalse(second.isDone()); + end.invoke(manager, uuid); + assertTrue(first.get(5, TimeUnit.SECONDS).applied()); assertTrue(second.get(5, TimeUnit.SECONDS).applied()); + assertEquals(7, cache.getIfPresent("Points").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void synchronousWriterEndingDuringCompletionCannotScheduleTheNextAwardTwice() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var begin = UserDataManager.class.getDeclaredMethod("beginSharedSynchronousIntegerMutation", UUID.class); begin.setAccessible(true); + var end = UserDataManager.class.getDeclaredMethod("endSharedSynchronousIntegerMutation", UUID.class); end.setAccessible(true); + var workerBlocked = new java.util.concurrent.CountDownLatch(1); var startWorker = new java.util.concurrent.CountDownLatch(1); + var callbackEntered = new java.util.concurrent.CountDownLatch(1); var finishCallback = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { workerBlocked.countDown(); try { startWorker.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + boolean admitted = false; + try { + assertTrue(workerBlocked.await(5, TimeUnit.SECONDS)); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null); + first.whenComplete((value, failure) -> { callbackEntered.countDown(); try { finishCallback.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + startWorker.countDown(); assertTrue(callbackEntered.await(5, TimeUnit.SECONDS)); + assertEquals(true, begin.invoke(manager, uuid)); admitted = true; + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null); + end.invoke(manager, uuid); admitted = false; + finishCallback.countDown(); manager.afterSharedMutationFinalization(first).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertTrue(second.toCompletableFuture().get(5, TimeUnit.SECONDS).applied()); + manager.getTimer().submit(() -> {}).get(5, TimeUnit.SECONDS); + assertEquals(3, cache.getIfPresent("Points").getInt(), "the queued award must run exactly once"); + } finally { if (admitted) end.invoke(manager, uuid); startWorker.countDown(); finishCallback.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void pendingSharedObserverWaitsForCapturedOperationsAndCannotCancelThem() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null); + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null); + var observer = manager.pendingSharedIntegerMutations(uuid).toCompletableFuture(); + var cancelled = manager.pendingSharedIntegerMutations(uuid).toCompletableFuture(); + assertFalse(observer.isDone()); assertTrue(cancelled.cancel(false)); assertFalse(first.toCompletableFuture().isDone()); + release.countDown(); observer.get(5, TimeUnit.SECONDS); + assertTrue(first.toCompletableFuture().get(5, TimeUnit.SECONDS).applied()); assertTrue(second.toCompletableFuture().get(5, TimeUnit.SECONDS).applied()); + assertEquals(3, cache.getIfPresent("Points").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void pendingSharedObserverPropagatesAnAdmittedOperationFailure() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(Integer.MAX_VALUE)))); manager.getUserDataCache().put(uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var award = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null); + var observer = manager.pendingSharedIntegerMutations(uuid).toCompletableFuture(); assertFalse(observer.isDone()); + release.countDown(); assertThrows(java.util.concurrent.ExecutionException.class, () -> award.toCompletableFuture().get(5, TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.ExecutionException.class, () -> observer.get(5, TimeUnit.SECONDS)); + assertEquals(Integer.MAX_VALUE, cache.getIfPresent("Points").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void deferredSharedAwardRejectionDoesNotCallObserversUnderManagerLock() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); + var begin = UserDataManager.class.getDeclaredMethod("beginSharedSynchronousIntegerMutation", UUID.class); begin.setAccessible(true); + var end = UserDataManager.class.getDeclaredMethod("endSharedSynchronousIntegerMutation", UUID.class); end.setAccessible(true); + assertEquals(true, begin.invoke(manager, uuid)); + try { + var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var observer = receipt.handle((value, failure) -> { assertNotNull(failure); assertFalse(Thread.holdsLock(manager)); return null; }); + manager.getTimer().shutdownNow(); end.invoke(manager, uuid); observer.get(5, TimeUnit.SECONDS); + assertFalse(manager.hasUnsettledLegacyIntegerMutations()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void uncertainSharedAwardBlocksLaterAwardAndRetirement() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = spy(new UserDataCache(manager, uuid)); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var attempted = new java.util.concurrent.CountDownLatch(1); + doAnswer(call -> { attempted.countDown(); throw new UserDataCache.SharedIntegerMutationUncertainException(new IllegalStateException("uncertain")); }) + .doCallRealMethod().when(cache).tryMutateIntegers(anyMap(), any(), any()); + try { + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 2), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertTrue(attempted.await(5, TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.TimeoutException.class, () -> second.get(250, TimeUnit.MILLISECONDS)); + assertFalse(first.isDone()); assertEquals(1, cache.getIfPresent("Points").getInt()); + assertFalse(manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().isDone()); verify(backend, never()).close(); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void unsupportedReplayProviderRejectsBeforeAdmission() { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + try { + assertThrows(UnsupportedOperationException.class, () -> manager.submitSharedReplayTransaction(UUID.randomUUID(), UserStorage.SQLITE, Map.of(), scope -> null)); + assertFalse(manager.hasUnsettledLegacyIntegerMutations()); verify(backend, never()).user(any()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void rejectedSharedAwardHasNoLaterMutation() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + manager.getTimer().shutdownNow(); + var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertThrows(java.util.concurrent.ExecutionException.class, () -> receipt.get(5, TimeUnit.SECONDS)); + assertEquals(1, cache.getIfPresent("Points").getInt()); + assertFalse(manager.hasUnsettledLegacyIntegerMutations()); + } + @Test void sharedAwardCompletionCannotAwaitItsOwnRetirement() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var award = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var callback = award.thenRun(() -> assertThrows(java.util.concurrent.CompletionException.class, + () -> manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().join())); + release.countDown(); callback.get(5, TimeUnit.SECONDS); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("Points").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void sharedAwardReceiptsPreserveAdmissionOrderAndRejectRetirement() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = spy(new UserDataManager(plugin)); + doAnswer(call -> { call.getArgument(0).run(); return null; }).when(manager).dispatchSharedUserDataNotification(any(Runnable.class)); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var callbacks = new java.util.concurrent.atomic.AtomicInteger(); var overtook = new java.util.concurrent.atomic.AtomicBoolean(); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Activations", 2), UserDataCache.IntegerMutation.ADD, + keys -> { callbacks.incrementAndGet(); overtook.set(cache.tryMutateIntegers(Map.of("Activations", 99), UserDataCache.IntegerMutation.ADD, null) != null); }).toCompletableFuture(); + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Activations", 10), UserDataCache.IntegerMutation.SET, null).toCompletableFuture(); + var third = manager.submitSharedIntegerMutation(uuid, Map.of("Activations", 4), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var callback = first.thenRun(() -> assertNull(cache.tryMutateIntegers(Map.of("Activations", 99), UserDataCache.IntegerMutation.ADD, null))); + var retirement = manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture(); + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> manager.submitSharedIntegerMutation(uuid, Map.of("Activations", 99), UserDataCache.IntegerMutation.ADD, null)); + release.countDown(); first.get(5, TimeUnit.SECONDS); callback.get(5, TimeUnit.SECONDS); second.get(5, TimeUnit.SECONDS); third.get(5, TimeUnit.SECONDS); + retirement.get(5, TimeUnit.SECONDS); assertEquals(1, callbacks.get()); assertFalse(overtook.get()); + assertEquals(14, cache.getIfPresent("Activations").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void rejectedPendingAwardIsTerminalAndDoesNotApply() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); manager.getTimer().shutdownNow(); + var result = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + assertThrows(java.util.concurrent.ExecutionException.class, () -> result.get(2, TimeUnit.SECONDS)); + assertTrue(manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture().isDone()); + assertEquals(1, cache.getIfPresent("Activations").getInt()); + } + @Test void rejectedSuccessorDoesNotReplayCompletedAward() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + try { + var first = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, + keys -> manager.getTimer().shutdown()).toCompletableFuture(); + var second = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 2), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + manager.endLegacyDirectWrite(direct); direct = null; + assertTrue(first.get(5, TimeUnit.SECONDS).applied()); + assertThrows(java.util.concurrent.ExecutionException.class, () -> second.get(5, TimeUnit.SECONDS)); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + assertTrue(manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture().isDone()); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void runtimeShutdownKeepsStorageOwnerUntilPendingAwardFlushes() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID uuid = UUID.randomUUID(); + when(plugin.isLoadUserData()).thenReturn(true); when(plugin.getUserManager()).thenReturn(users); + when(plugin.getLoadedUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + try { + var award = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var platform = new com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform(plugin); + var method = platform.getClass().getDeclaredMethod("closeUserStorageAfterSharedRetirement"); method.setAccessible(true); + var shutdown = ((java.util.concurrent.CompletionStage) method.invoke(platform)).toCompletableFuture(); + assertFalse(shutdown.isDone()); verify(plugin, never()).closePendingNativeUserStorageOwners(); + manager.endLegacyDirectWrite(direct); direct = null; + assertTrue(award.get(5, TimeUnit.SECONDS).applied()); shutdown.get(5, TimeUnit.SECONDS); + verify(data).setValues(any(HashMap.class)); verify(plugin).closePendingNativeUserStorageOwners(); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void shutdownWorkerReportsUnexpectedRetirementFailure() throws Exception { + var manager = spy(new UserDataManager(mock(AdvancedCorePlugin.class))); + doThrow(new IllegalStateException("retirement failed")).when(manager).clearCacheAsyncCompletion(); + try { + var shutdown = manager.shutdownLegacyIntegerMutations().toCompletableFuture(); + assertThrows(java.util.concurrent.ExecutionException.class, () -> shutdown.get(5, TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void shutdownLegacyFlushDoesNotBlockCallingThread() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + doAnswer(invocation -> { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); return null; }).when(data).setValues(any(HashMap.class)); + var manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); + var cache = new UserDataCache(manager, uuid); manager.getUserDataCache().put(uuid, cache); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + cache.addChange(new UserDataChangeInt("Activations", 2), true); + try { + var shutdown = manager.shutdownLegacyIntegerMutations().toCompletableFuture(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); assertFalse(shutdown.isDone()); + release.countDown(); shutdown.get(5, TimeUnit.SECONDS); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void pendingLegacyAwardLoadsStrictColdBalanceBeforeApplying() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var stored = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + when(users.getUser(uuid, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(7)))); + var manager = new UserDataManager(plugin); + try { + var result = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), + UserDataCache.IntegerMutation.ADD, null).toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(7, result.previous().get("Activations")); + assertEquals(8, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void pendingAwardUsesValuePublishedByCompetingDirectSetter() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var stored = mock(AdvancedCoreUser.class); var data = mock(UserData.class); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + when(users.getUser(uuid, false)).thenReturn(stored); when(stored.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(10)))); + var manager = new UserDataManager(plugin); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + try { + var award = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var accepted = direct; + UserDataCache.runWithLegacyDirectPublication(manager, accepted, cache, + () -> cache.addChange(new UserDataChangeInt("Activations", 10), false)); + manager.markLegacyDirectWriteSucceeded(direct); manager.endLegacyDirectWrite(direct); direct = null; + award.get(5, TimeUnit.SECONDS); + assertEquals(11, cache.getIfPresent("Activations").getInt()); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } + @Test void pendingLegacyAwardDoesNotRepeatAfterNotificationFailure() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var notices = new java.util.concurrent.atomic.AtomicInteger(); + var retirementRejected = new java.util.concurrent.atomic.AtomicBoolean(); + try { + var result = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, + keys -> { + notices.incrementAndGet(); + retirementRejected.set(manager.clearCacheAsyncCompletion().toCompletableFuture().isCompletedExceptionally()); + throw new IllegalStateException("notification failed"); + }) + .toCompletableFuture().get(5, TimeUnit.SECONDS); + assertTrue(result.applied()); assertEquals(2, cache.getIfPresent("Activations").getInt()); + assertEquals(1, notices.get()); assertTrue(retirementRejected.get()); assertNotNull(manager.getLastDeferredStorageFailure()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void synchronousLegacyAwardIsNotReportedFailedAfterListenerFailure() { + var plugin = mock(AdvancedCorePlugin.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); var notices = new java.util.concurrent.atomic.AtomicInteger(); + try { + var result = manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, + keys -> { notices.incrementAndGet(); throw new IllegalStateException("notification failed"); }); + assertTrue(result.applied()); assertEquals(2, cache.getIfPresent("Activations").getInt()); + assertEquals(1, notices.get()); assertNotNull(manager.getLastDeferredStorageFailure()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyCapabilityChangeDuringInspectionRequiresRetry() { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); UUID uuid = UUID.randomUUID(); + var first = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var second = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(first); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, first)); + var data = new UserData(user) { + @Override public boolean supportsStrictCompleteValues() { + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, second)); + return false; + } + }; + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + var manager = new UserDataManager(plugin); + try { + assertEquals(UserDataManager.LegacyPopulationResult.RETRY, manager.populateCompleteLegacyCacheIfSupported(uuid)); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void directLegacySetterSurvivesAnOlderInFlightCreditFlush() throws Exception { directSetterWithProviderParticipation(true); } + @Test void opaqueLegacyProviderDoesNotGainAtomicOrdering() throws Exception { directSetterWithProviderParticipation(false); } + private void directSetterWithProviderParticipation(boolean participates) throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var providerAdmission = new Object(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { synchronized (providerAdmission) { stored.set(((DataValue) call.getArgument(2)).getInt()); } return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + doAnswer(call -> { entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + synchronized (providerAdmission) { + if (participates) UserDataCache.rejectStaleLegacyBatch(uuid.toString()); + for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); + } return null; }) + .when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + var worker = Executors.newSingleThreadExecutor(); + try { + assertTrue(manager.populateCompleteLegacyCache(uuid)); + UserDataCache cache = manager.getPublishedCache(uuid); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + var flush = worker.submit(cache::processChanges); assertTrue(entered.await(5, TimeUnit.SECONDS)); + data.setInt("Activations", 10, false); + assertEquals(10, stored.get()); + release.countDown(); flush.get(5, TimeUnit.SECONDS); + assertEquals(participates ? 10 : 2, stored.get()); + if (participates) assertEquals(10, cache.getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void directLegacySetterSurvivesOlderQueuedCreditFlush() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> new ArrayList<>(List.of(new Column("Activations", new DataValueInt(stored.get()))))); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("Activations")) stored.set(column.getValue().getInt()); return null; }) + .when(table).update(eq(uuid.toString()), anyList(), eq(false)); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + try { + assertTrue(manager.populateCompleteLegacyCache(uuid)); + UserDataCache cache = manager.getPublishedCache(uuid); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + data.setInt("Activations", 10, false); + cache.processChanges(); + assertEquals(10, cache.getIfPresent("Activations").getInt()); + assertEquals(10, stored.get()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void directLegacySetterCannotBeLostBehindAnOlderStrictRead() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(AdvancedCoreUser.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(table); + var data = new UserData(user); when(user.getUserData()).thenReturn(data); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + var reads = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { stored.set(((DataValue) call.getArgument(2)).getInt()); return null; }).when(table).update(eq(uuid.toString()), eq("Activations"), any(DataValue.class)); + when(table.getExactStrict(uuid.toString())).thenAnswer(call -> { + int value = stored.get(); + if (reads.getAndIncrement() == 0) { + entered.countDown(); if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + } + return new ArrayList<>(List.of(new Column("Activations", new DataValueInt(value)))); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var load = worker.submit(() -> manager.populateCompleteLegacyCache(uuid)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + data.setInt("Activations", 10); + release.countDown(); assertFalse(load.get(5, TimeUnit.SECONDS)); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(11, manager.getPublishedCache(uuid).getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyLoadRejectsRetiredGeneration() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + UserData data = plugin.getUserManager().getUser(uuid, false).getUserData(); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + when(data.getValuesIfStrictComplete()).thenAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + return new HashMap<>(Map.of("Activations", new DataValueInt(1))); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var load = worker.submit(() -> manager.populateCompleteLegacyCache(uuid)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + UserDataCache generation = manager.getUserDataCache().get(uuid); + generation.beginRemoval(); + assertTrue(manager.retireSharedCache(uuid, generation)); + UserDataCache replacement = new UserDataCache(manager, uuid); + replacement.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(9)))); + assertNull(manager.getUserDataCache().putIfAbsent(uuid, replacement)); + release.countDown(); + assertFalse(load.get(5, TimeUnit.SECONDS)); + assertNull(generation.getIfPresent("Activations")); + assertSame(replacement, manager.getUserDataCache().get(uuid)); + assertEquals(9, replacement.getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void strictLegacyPopulationDoesNotInventBalanceAfterFailureAndCanRetry() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + UserData data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getValuesIfStrictComplete()).thenThrow(new IllegalStateException("synthetic read failure")) + .thenReturn(null).thenReturn(new HashMap<>()); + try { + assertThrows(IllegalStateException.class, () -> manager.populateCompleteLegacyCache(uuid)); + assertThrows(IllegalStateException.class, () -> manager.populateCompleteLegacyCache(uuid)); + assertNull(manager.tryMutateLegacyIntegers(uuid, Map.of("missing", 1), UserDataCache.IntegerMutation.ADD, null)); + assertTrue(manager.populateCompleteLegacyCache(uuid)); + assertTrue(manager.tryMutateLegacyIntegers(uuid, Map.of("missing", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertEquals(1, manager.getPublishedCache(uuid).getIfPresent("missing").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void failedLegacyColdLoadCanRetryOnTheSameGeneration() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + UserData data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenThrow(new IllegalStateException("synthetic read failure")) + .thenReturn(new HashMap<>(Map.of("Activations", new DataValueInt(4)))); + try { + assertThrows(IllegalStateException.class, () -> manager.cacheUser(uuid, null)); + UserDataCache generation = manager.getUserDataCache().get(uuid); + assertNotNull(generation); + assertSame(generation, manager.getCache(uuid)); + assertEquals(4, generation.getIfPresent("Activations").getInt()); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void legacyColdLoadCannotReplaceAnAcceptedGeneration() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + UserData data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + when(data.getValues()).thenAnswer(call -> { + entered.countDown(); + if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + return new HashMap<>(Map.of("Activations", new DataValueInt(1))); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var load = worker.submit(() -> manager.cacheUser(uuid, null)); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + UserDataCache cache = manager.getUserDataCache().get(uuid); + assertNotNull(cache, "cold load must publish its canonical generation before reading"); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); + assertTrue(cache.tryMutateLegacyIntegers(Map.of("Activations", 1), + UserDataCache.IntegerMutation.ADD, null).applied()); + release.countDown(); + load.get(5, TimeUnit.SECONDS); + assertSame(cache, manager.getUserDataCache().get(uuid)); + assertEquals(2, cache.getIfPresent("Activations").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void storageSpecificValuesOverrideRemainsInUse() throws Exception { + try (Fixture fixture = new Fixture()) { + var user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + var custom = new UserData(user) { + @Override public ArrayList getKeys() { return new ArrayList<>(List.of("custom")); } + @Override public HashMap getValues(UserStorage storage) { + return new HashMap<>(Map.of("custom", new DataValueInt(7))); + } + }; + when(user.getUserData()).thenReturn(custom); + SharedUserDataRuntime runtime = fixture.runtime(); + try { + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + assertEquals(7, cache.getIfPresent("custom").getInt()); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("missing")); + } finally { runtime.close(); } + } + } @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)))); + when(data.getValuesIfComplete()).thenReturn(new HashMap<>(Map.of("VoteShopLimitDaily", new DataValueInt(4)))); SharedUserDataRuntime runtime = fixture.runtime(); UserDataCache cache = fixture.manager.getCache(fixture.uuid); @@ -58,6 +2114,22 @@ class SharedCacheBindingRegressionTest { } } + @Test void customValuesReaderIsRetainedWithoutCertifyingMissingKeys() throws Exception { + try (Fixture fixture = new Fixture()) { + var user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + var custom = new UserData(user) { + @Override public ArrayList getKeys() { return new ArrayList<>(List.of("custom")); } + @Override public HashMap getValues() { return new HashMap<>(Map.of("custom", new DataValueInt(7))); } + }; + when(user.getUserData()).thenReturn(custom); + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + assertEquals(7, cache.getIfPresent("custom").getInt()); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("missing")); + runtime.close(); + } + } + @Test void noDatabaseLookupMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.NO_DB_LOOKUP); } @Test void cacheOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.CACHE_ONLY); } @Test void temporaryOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.TEMP_ONLY); } @@ -187,7 +2259,7 @@ class SharedCacheBindingRegressionTest { throw new AssertionError(interrupted); } return new HashMap(); - }).when(data).getValues(); + }).when(data).getValuesIfComplete(); MemoryBackend replacement = new MemoryBackend(UserStorage.SQLITE); var workers = Executors.newFixedThreadPool(2); try { 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 index 31c0fd8c4a..1d5bc09dec 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java @@ -135,6 +135,19 @@ private void queuedWrite(boolean flushFirst) throws Exception { } } + @Test void nullRowNeverAuthorizesMissingKeyDefaultInActualCacheOwner() throws Exception { + try (Fixture fixture = new Fixture()) { + fixture.backend.holdFirstRead.set(false); + fixture.backend.nullRow = true; + fixture.runtime.populate(fixture.uuid); + assertFalse(fixture.cache.seedMissingIntegerFromCompleteSnapshot("missing")); + fixture.backend.nullRow = false; fixture.backend.rows.clear(); + fixture.runtime.populate(fixture.uuid); + assertTrue(fixture.cache.seedMissingIntegerFromCompleteSnapshot("missing")); + assertEquals(0, fixture.cache.getIfPresent("missing").getInt()); + } + } + @Test void populationTokenCannotBeAppliedToAnotherUser() throws Exception { try (Fixture fixture = new Fixture()) { var token = fixture.owner.beginPopulation(fixture.uuid); @@ -190,11 +203,13 @@ public void close() throws Exception { } private static final class MemoryBackend implements SqlUserBackend { + @Override public boolean hasAuthoritativeRowReads() { return true; } 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; + volatile boolean nullRow; boolean open = true; MemoryBackend(UUID uuid) { this.uuid = uuid; } public UserStorage storageType() { return UserStorage.SQLITE; } @@ -205,6 +220,7 @@ public SqlUserStorage user(UUID id) { assertEquals(uuid, id); return new SqlUserStorage() { public List readRow(UserStorage type) { + if (nullRow) return null; List snapshot = new ArrayList<>(); rows.forEach((key, value) -> snapshot.add(new Column(key, value))); if (holdFirstRead.compareAndSet(true, false)) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java index 9894d4b957..9ff7d1f154 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java @@ -58,6 +58,89 @@ import com.bencodez.simpleapi.sql.sqlite.db.SQLite; class SharedUserStorageReloadSafetyTest { + @Test + void shutdownWaitsForLastMutationInsideLifecycleCallback() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SqlUserBackend backend = mock(SqlUserBackend.class); + UserCacheOwner owner = mock(UserCacheOwner.class); + UUID uuid = UUID.randomUUID(); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + when(backend.isOpen()).thenReturn(true); + when(backend.user(uuid)).thenReturn(mock(SqlUserStorage.class)); + when(owner.cachedUsers()).thenReturn(Set.of(uuid)); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + manager.bindSharedRuntime(runtime); + Method begin = UserDataManager.class.getDeclaredMethod("beginSharedSynchronousIntegerMutation", UUID.class); + Method end = UserDataManager.class.getDeclaredMethod("endSharedSynchronousIntegerMutation", UUID.class); + begin.setAccessible(true); end.setAccessible(true); + CountDownLatch admitted = new CountDownLatch(1), release = new CountDownLatch(1); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + java.util.concurrent.atomic.AtomicInteger closes = new java.util.concurrent.atomic.AtomicInteger(); + try { + var mutation = worker.submit(() -> runtime.withStorageReadAdmission(() -> { + try { + assertEquals(true, begin.invoke(manager, uuid)); + admitted.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + end.invoke(manager, uuid); + return null; + } catch (ReflectiveOperationException | InterruptedException e) { throw new RuntimeException(e); } + })); + assertTrue(admitted.await(5, TimeUnit.SECONDS)); + CompletionStage close = manager.closeSharedRuntimeAsyncCompletion(closes::incrementAndGet); + assertFalse(close.toCompletableFuture().isDone()); + release.countDown(); + mutation.get(5, TimeUnit.SECONDS); + close.toCompletableFuture().get(5, TimeUnit.SECONDS); + verify(owner).flush(eq(uuid), eq(UserStorage.MYSQL), any()); + verify(backend).close(); + assertEquals(1, closes.get()); + assertFalse(manager.hasSharedRuntimeLifecycle()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + + @Test + void synchronousCloseRejectionRestoresRuntimeForRetry() { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(runtime.closeAsync(any())).thenThrow(new IllegalStateException("reentrant")) + .thenReturn(CompletableFuture.completedFuture(null)); + manager.bindSharedRuntime(runtime); + try { + assertThrows(java.util.concurrent.CompletionException.class, + () -> manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().join()); + assertTrue(manager.hasSharedRuntimeLifecycle()); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().join(); + assertFalse(manager.hasSharedRuntimeLifecycle()); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test + void rejectedDrainContinuationKeepsOwnerAvailableForRetry() throws Exception { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(runtime.closeAsync(any())).thenReturn(CompletableFuture.completedFuture(null)); + manager.bindSharedRuntime(runtime); + var original = manager.getTimer(); + var rejected = mock(java.util.concurrent.ScheduledExecutorService.class); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic rejection")).when(rejected).execute(any()); + Field timerField = UserDataManager.class.getDeclaredField("timer"); timerField.setAccessible(true); timerField.set(manager, rejected); + Method begin = UserDataManager.class.getDeclaredMethod("beginSharedSynchronousIntegerMutation", UUID.class); + Method end = UserDataManager.class.getDeclaredMethod("endSharedSynchronousIntegerMutation", UUID.class); + begin.setAccessible(true); end.setAccessible(true); UUID uuid = UUID.randomUUID(); + var caller = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + assertEquals(true, begin.invoke(manager, uuid)); + var shutdown = caller.submit(() -> manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture()).get(5, TimeUnit.SECONDS); + end.invoke(manager, uuid); + assertThrows(java.util.concurrent.CompletionException.class, shutdown::join); + assertTrue(manager.hasSharedRuntimeLifecycle()); + timerField.set(manager, original); + manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().get(5, TimeUnit.SECONDS); + verify(runtime).closeAsync(any()); + } finally { caller.shutdownNow(); timerField.set(manager, original); original.shutdownNow(); } + } @Test void primaryThreadCompatibilityConversionDelegatesToTheAsyncEntryPoint() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); 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 ff124ba771..b4b4f1ba05 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 @@ -158,11 +158,14 @@ public void cachedReadUsesAtomicCacheAccessor() { UserManager users = mock(UserManager.class); UserDataManager manager = mock(UserDataManager.class); UserDataCache cache = mock(UserDataCache.class); + UUID uuid = UUID.randomUUID(); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); when(user.getCache()).thenReturn(cache); when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(users.getDataManager()).thenReturn(manager); + when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache))); when(manager.effectiveStorageType(UserStorage.SQLITE)).thenReturn(UserStorage.SQLITE); when(cache.getIfPresent("Points")).thenReturn(new DataValueInt(23)); when(cache.getEntryIfPresent("PlayerName")).thenReturn( diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserTablePreparedStatementTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserTablePreparedStatementTest.java index 2a96fb0255..5b807e36de 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserTablePreparedStatementTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserTablePreparedStatementTest.java @@ -26,6 +26,2399 @@ import com.bencodez.simpleapi.sql.sqlite.db.SQLite; class UserTablePreparedStatementTest { + @Test void bindingSharedStoragePreservesNewerLegacyBulkValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + var stored = new java.util.concurrent.atomic.AtomicInteger(f.stored()); + f.cache.configureSharedStorage(values -> stored.set(values.get("X").getInt()), Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(10, stored.get()); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 30), true); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(30, stored.get()); + } + } + @Test void bindingDifferentSharedStorageCannotRouteLegacyQueue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + var writes = new java.util.concurrent.atomic.AtomicInteger(); + f.cache.configureSharedStorage(values -> writes.incrementAndGet(), Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.cache.processChanges()); + org.junit.jupiter.api.Assertions.assertEquals(0, writes.get()); org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); + } + } + @Test void scalarIntegerAfterBulkOverrideSurvivesOlderQueuedWrite() throws Exception { scalarAfterBulkOverride(false); } + @Test void scalarStringAfterBulkOverrideSurvivesOlderQueuedWrite() throws Exception { scalarAfterBulkOverride(true); } + @Test void laterQueuedChangeStillSupersedesScalarPublication() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var first = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (first.compareAndSet(false, true)) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.eq("X")); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var direct = pool.submit(() -> f.data.setInt("X", 20, false)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + f.data.setInt("X", 30, true); release.countDown(); direct.get(5, java.util.concurrent.TimeUnit.SECONDS); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(30, f.stored()); + } finally { release.countDown(); pool.shutdownNow(); } + } + } + private void scalarAfterBulkOverride(boolean stringValue) throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.data.setInt("X", 2, true); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + if (stringValue) f.data.setString("X", "20", false); else f.data.setInt("X", 20, false); + org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void evictionCannotDetachInflightLegacyBatch() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); + var data = spy(f.data); when(f.user.getUserData()).thenReturn(data); + org.mockito.Mockito.doAnswer(call -> { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return call.callRealMethod(); }).when(data).setValues(any(java.util.HashMap.class)); + try { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + var flush = pool.submit(f.cache::processChanges); org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.CompletionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.manager.removeCache(f.uuid, null)); + org.junit.jupiter.api.Assertions.assertSame(f.cache, f.manager.getUserDataCache().get(f.uuid)); + release.countDown(); flush.get(5, java.util.concurrent.TimeUnit.SECONDS); f.manager.removeCache(f.uuid, null); + org.junit.jupiter.api.Assertions.assertEquals(2, f.stored()); + } finally { release.countDown(); pool.shutdownNow(); } + } + } + @Test void laterStagedSqliteAliasCannotCrossCheckpointBoundary() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var armed = new java.util.concurrent.atomic.AtomicBoolean(); var observed = new java.util.concurrent.atomic.AtomicInteger(); + var earlier = new com.bencodez.advancedcore.api.user.usercache.change.UserDataChange("X") { + @Override public com.bencodez.simpleapi.sql.data.DataValue toUserDataValue() { + if (armed.compareAndSet(true, false)) f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("x", 20), true); + return new DataValueInt(10); + } + @Override public void dump() { setKey(null); } + }; + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(earlier, true); armed.set(true); + f.cache.flushChangesAndRun(() -> { try { observed.set(f.stored()); } catch (Exception e) { throw new IllegalStateException(e); } }); + }); + org.junit.jupiter.api.Assertions.assertEquals(10, observed.get()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void evictionWaitingForCheckpointPersistsStagedChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var evictionStarted = new java.util.concurrent.CountDownLatch(1); + var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var checkpoint = pool.submit(() -> f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 40), true); entered.countDown(); + try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + })); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + var eviction = pool.submit(() -> { evictionStarted.countDown(); f.manager.removeCache(f.uuid, null); }); + org.junit.jupiter.api.Assertions.assertTrue(evictionStarted.await(5, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.TimeoutException.class, () -> eviction.get(100, java.util.concurrent.TimeUnit.MILLISECONDS)); + release.countDown(); checkpoint.get(5, java.util.concurrent.TimeUnit.SECONDS); eviction.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(40, f.stored()); org.junit.jupiter.api.Assertions.assertFalse(f.manager.getUserDataCache().containsKey(f.uuid)); + } finally { release.countDown(); pool.shutdownNow(); } + } + } + @Test void laterStagedChangeDoesNotLeakThroughEarlierBulkOverride() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))); + var armed = new java.util.concurrent.atomic.AtomicBoolean(); var observed = new java.util.concurrent.atomic.AtomicInteger(); + var earlier = new com.bencodez.advancedcore.api.user.usercache.change.UserDataChange("X") { + @Override public com.bencodez.simpleapi.sql.data.DataValue toUserDataValue() { + if (armed.compareAndSet(true, false)) f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + return new DataValueInt(10); + } + @Override public void dump() { setKey(null); } + }; + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(earlier, true); armed.set(true); + f.cache.flushChangesAndRun(() -> { try { observed.set(f.stored()); } catch (Exception e) { throw new IllegalStateException(e); } }); + }); + org.junit.jupiter.api.Assertions.assertEquals(10, observed.get()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void newerDirectBulkOverrideStillSupersedesEarlierQueuedChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var observed = new java.util.concurrent.atomic.AtomicInteger(); + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(15)))); + f.cache.flushChangesAndRun(() -> { try { observed.set(f.stored()); } catch (Exception e) { throw new IllegalStateException(e); } }); + }); + org.junit.jupiter.api.Assertions.assertEquals(15, observed.get()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(15, f.stored()); + } + } + @Test void retirementCannotAcknowledgeStagedCheckpointChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.CompletionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + }); + org.junit.jupiter.api.Assertions.assertSame(f.cache, f.manager.getUserDataCache().get(f.uuid)); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void dumpCannotDiscardStagedCheckpointChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.cache.dump()); + }); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void copiedBatchMapRetainsDestinationAcrossReload() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var copyingData = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { + var token = com.bencodez.advancedcore.api.user.usercache.UserDataCache.captureLegacyBatchToken(values); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var copy = new java.util.HashMap<>(values); + if (token == null) super.setValues(copy); + else com.bencodez.advancedcore.api.user.usercache.UserDataCache.withLegacyBatchCopy(token, copy, () -> super.setValues(copy)); + } + }; + when(f.user.getUserData()).thenReturn(copyingData); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.cache.processChanges(); + org.mockito.Mockito.verifyNoInteractions(mysql); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void copiedBatchTokenRejectsUnrelatedAndExpiredUse() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var saved = new java.util.concurrent.atomic.AtomicReference(); + var copyingData = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { + var token = com.bencodez.advancedcore.api.user.usercache.UserDataCache.captureLegacyBatchToken(values); + org.junit.jupiter.api.Assertions.assertNotNull(token); saved.set(token); + var independent = new java.util.HashMap<>(values); + org.junit.jupiter.api.Assertions.assertNull(com.bencodez.advancedcore.api.user.usercache.UserDataCache.captureLegacyBatchToken(independent)); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.withLegacyBatchCopy(token, independent, () -> { + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> com.bencodez.advancedcore.api.user.usercache.UserDataCache.withLegacyBatchCopy(token, independent, () -> {})); + super.setValues(independent); + }); + } + }; + when(f.user.getUserData()).thenReturn(copyingData); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> com.bencodez.advancedcore.api.user.usercache.UserDataCache.withLegacyBatchCopy(saved.get(), new java.util.HashMap<>(), () -> {})); + } + } + @Test void independentReentrantWriteDoesNotInheritBatchDestination() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var nested = new java.util.concurrent.atomic.AtomicBoolean(); + var copyingData = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { + var token = com.bencodez.advancedcore.api.user.usercache.UserDataCache.captureLegacyBatchToken(values); + if (nested.compareAndSet(false, true)) super.setValues(com.bencodez.advancedcore.api.user.UserStorage.MYSQL, new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(20)))); + var copy = new java.util.HashMap<>(values); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.withLegacyBatchCopy(token, copy, () -> super.setValues(copy)); + } + }; + when(f.user.getUserData()).thenReturn(copyingData); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); f.cache.processChanges(); + org.mockito.Mockito.verify(mysql, org.mockito.Mockito.atLeastOnce()).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq(false)); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void nestedStagingRemainsFencedUntilOuterCheckpointCompletes() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.flushChangesAndRun(() -> { + f.cache.flushChangesAndRun(() -> f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true)); + f.cache.processChanges(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("UPDATE Users SET X=5"); } + catch (Exception e) { throw new IllegalStateException(e); } + }); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void sqliteDirectAliasUpdatesCanonicalCacheValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.data.setInt("x", 10, false); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(10, f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY)); + } + } + @Test void copiedBatchMapCannotHideProviderFailure() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var copyingData = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { + super.setValues(new java.util.HashMap<>(values)); + } + }; + when(f.user.getUserData()).thenReturn(copyingData); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_update BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.cache.processChanges()); + org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_update"); } + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void callbackCheckpointSupersedesOuterSqliteDirectWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { + if (called.compareAndSet(false, true)) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + f.cache.flushChangesAndRun(() -> { }); + } + return null; + }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.eq("X")); + f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(20, f.cache.getIfPresent("X").getInt()); + } + } + @Test void opaqueCallbackBatchCannotSuppressNativeDirectWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var opaque = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { } + }; + when(f.user.getUserData()).thenReturn(opaque); var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (called.compareAndSet(false, true)) { f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); f.cache.flushChangesAndRun(() -> {}); } return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.eq("X")); + f.data.setInt("X", 10, false); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void callbackBatchDoesNotSuppressDifferentSqliteRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String upper = f.uuid.toString().toUpperCase(java.util.Locale.ROOT); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + upper + "',1)"); } + var other = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(other.getPlugin()).thenReturn(f.plugin); when(other.getUUID()).thenReturn(upper); when(other.isCached()).thenReturn(true); when(other.getCache()).thenReturn(f.cache); + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (called.compareAndSet(false, true)) { f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); f.cache.flushChangesAndRun(() -> {}); } return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(other), org.mockito.ArgumentMatchers.eq("X")); + new com.bencodez.advancedcore.api.user.UserData(other).setInt("X", 10, false); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(20, row.getInt(1)); } + sql.setString(1, upper); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } + } + } + @Test void callbackBatchOnDifferentStoreDoesNotSuppressSqliteWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + org.mockito.Mockito.doAnswer(call -> { com.bencodez.advancedcore.api.user.usercache.UserDataCache.recordLegacyBatchProviderCommit(f.uuid.toString(), java.util.List.of("X"), com.bencodez.advancedcore.api.user.UserStorage.MYSQL, false); return null; }).when(mysql).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq(false)); + var redirected = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { super.setValues(com.bencodez.advancedcore.api.user.UserStorage.MYSQL, values); } + }; + when(f.user.getUserData()).thenReturn(redirected); var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (called.compareAndSet(false, true)) { f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); f.cache.flushChangesAndRun(() -> {}); } return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.eq("X")); + f.data.setInt("X", 10, false); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void movedSqliteRowInvalidatesDestinationCache() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var destination = java.util.UUID.randomUUID(); + var target = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, destination); + target.updateCompleteCachePreservingPending(new java.util.HashMap<>()); f.manager.getUserDataCache().put(destination, target); + f.data.setInt("X", 50, false); f.data.setString("uuid", destination.toString(), false); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(destination, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + } + } + @Test void olderDestinationWriteCannotOverwriteMovedRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String destination = java.util.UUID.randomUUID().toString(); f.data.setInt("X", 50, false); + var other = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); when(other.getPlugin()).thenReturn(f.plugin); when(other.getUUID()).thenReturn(destination); when(other.isCached()).thenReturn(false); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + new com.bencodez.advancedcore.api.user.UserData(other).setInt("X", 2, false, true); + f.data.setString("uuid", destination, false); task.get().run(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { sql.setString(1, destination); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(50, row.getInt(1)); } } + } + } + @Test void olderUppercaseDestinationWriteCannotOverwriteMovedRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String destination = java.util.UUID.randomUUID().toString().toUpperCase(java.util.Locale.ROOT); f.data.setInt("X", 50, false); + var canonical = java.util.UUID.fromString(destination); var canonicalCache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, canonical); + canonicalCache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(77)))); f.manager.getUserDataCache().put(canonical, canonicalCache); + var other = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); when(other.getPlugin()).thenReturn(f.plugin); when(other.getUUID()).thenReturn(destination); when(other.isCached()).thenReturn(false); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + new com.bencodez.advancedcore.api.user.UserData(other).setInt("X", 2, false, true); + f.data.setString("uuid", destination, false); task.get().run(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { sql.setString(1, destination); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(50, row.getInt(1)); } } + org.junit.jupiter.api.Assertions.assertEquals(77, canonicalCache.getIfPresent("X").getInt()); + } + } + @Test void opaqueCopiedBatchCanFlushDuringRetirement() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var copying = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { super.setValues(new java.util.HashMap<>(values)); } + }; + when(f.user.getUserData()).thenReturn(copying); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void olderSourceWriteCannotModifyRecreatedRowAfterMove() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Y INTEGER"); } + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 2, false, true); f.data.setString("uuid", java.util.UUID.randomUUID().toString(), false); f.data.setInt("Y", 7, false); + try { task.get().run(); } catch (IllegalStateException expected) { } + try (var sql = f.db.prepareStatement("SELECT X,Y FROM Users WHERE uuid=?")) { sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); row.getInt(1); org.junit.jupiter.api.Assertions.assertTrue(row.wasNull()); org.junit.jupiter.api.Assertions.assertEquals(7, row.getInt(2)); } } + } + } + @Test void reboundPrimaryMoveInvalidatesItsDestination() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String source = java.util.UUID.randomUUID().toString(); var destination = java.util.UUID.randomUUID(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + source + "',50)"); } + var target = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, destination); target.updateCompleteCachePreservingPending(new java.util.HashMap<>()); f.manager.getUserDataCache().put(destination, target); + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setString("uuid", destination.toString(), false, true); identity.set(source); task.get().run(); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(destination, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + } + } + @Test void reboundPrimaryMoveToOriginalIdentityInvalidatesDestination() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String source = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); sql.executeUpdate("INSERT INTO Users VALUES ('" + source + "',50)"); } + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>()); + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setString("uuid", f.uuid.toString(), false, true); identity.set(source); task.get().run(); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + } + } + @Test void failedPrimaryAliasDoesNotEnterUnfencedRetryQueue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_move BEFORE UPDATE ON Users WHEN NEW.uuid != OLD.uuid BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("UUID", java.util.UUID.randomUUID().toString(), false); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasChangesToProcess()); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void nestedAsyncWriteRetainsLegacyBindingAdmission() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Y INTEGER"); } + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var copying = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { super.setValues(values); f.data.setInt("Y", 3, false, true); } + }; + when(f.user.getUserData()).thenReturn(copying); f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertNotNull(task.get()); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.storageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.manager.bindSharedSqlBackend(backend, Runnable::run)); + task.get().run(); + } + } + @Test void destinationArithmeticWaitsForInFlightSqliteMove() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var destination = java.util.UUID.randomUUID(); var target = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, destination); + target.updateCompleteCachePreservingPending(new java.util.HashMap<>()); f.manager.getUserDataCache().put(destination, target); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var table = f.plugin.getSQLiteUserTable(); + org.mockito.Mockito.doAnswer(call -> { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return call.callRealMethod(); }).when(table).update(any(Column.class), org.mockito.ArgumentMatchers.anyList()); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var move = pool.submit(() -> f.data.setString("uuid", destination.toString(), false)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(destination, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + release.countDown(); move.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(destination, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + } finally { release.countDown(); pool.shutdownNow(); } + } + } + @Test void failedSqliteMoveLeavesDestinationSnapshotUsable() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var destination = java.util.UUID.randomUUID(); var target = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, destination); + target.updateCompleteCachePreservingPending(new java.util.HashMap<>()); f.manager.getUserDataCache().put(destination, target); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_move BEFORE UPDATE ON Users WHEN NEW.uuid != OLD.uuid BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("uuid", destination.toString(), false); + var result = f.manager.tryMutateLegacyIntegers(destination, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNotNull(result); org.junit.jupiter.api.Assertions.assertTrue(result.applied()); + } + } + @Test void callbackCheckpointForOtherColumnDoesNotSuppressDirectWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Y INTEGER DEFAULT 1"); } + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { + if (called.compareAndSet(false, true)) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Y", 20), true); + f.cache.flushChangesAndRun(() -> { }); + } + return null; + }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.eq("X")); + f.data.setInt("X", 10, false); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT X,Y FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); org.junit.jupiter.api.Assertions.assertEquals(20, row.getInt(2)); + } + } + } + @Test void warmPublicCacheLookupDefersInvalidatedStorageRefresh() throws Exception { + try (var f = new ReviewSqliteFixture()) { + when(f.plugin.getOptions()).thenReturn(new com.bencodez.advancedcore.AdvancedCoreConfigOptions()); + var table = f.plugin.getSQLiteUserTable(); var reads = new java.util.concurrent.atomic.AtomicInteger(); Thread caller = Thread.currentThread(); + org.mockito.Mockito.doAnswer(call -> { if (Thread.currentThread() == caller) reads.incrementAndGet(); return call.callRealMethod(); }).when(table).getExactStrict(any(Column.class)); + org.mockito.Mockito.doAnswer(call -> { if (Thread.currentThread() == caller) reads.incrementAndGet(); return call.callRealMethod(); }).when(table).getExact(any(Column.class)); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasPublishedStorageSnapshot()); + org.junit.jupiter.api.Assertions.assertSame(f.cache, f.manager.getCache(f.uuid)); + f.manager.cacheUserIfNeeded(f.uuid); + org.junit.jupiter.api.Assertions.assertEquals(0, reads.get()); + } + } + @Test void disappearedSqliteRowCannotAcknowledgeDirectUpdate() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); + var table = f.plugin.getSQLiteUserTable(); var once = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { + boolean present = (boolean) call.callRealMethod(); + if (present && once.compareAndSet(false, true)) try (var statement = f.db.prepareStatement("UPDATE Users SET uuid=? WHERE uuid=?")) { + statement.setString(1, replacement); statement.setString(2, f.uuid.toString()); statement.executeUpdate(); + } + return present; + }).when(table).containsKey(f.uuid.toString()); + f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { sql.setString(1, replacement); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } } + } + } + @Test void legacyArithmeticRejectsOldStorageGenerationAfterReload() throws Exception { + try (var f = new ReviewSqliteFixture()) { + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + org.mockito.Mockito.doAnswer(call -> { stored.set(call.getArgument(2).getInt()); return null; }).when(mysql).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.eq("X"), any(com.bencodez.simpleapi.sql.data.DataValue.class)); + f.data.setInt("X", 10, false); org.junit.jupiter.api.Assertions.assertEquals(10, stored.get()); + var add = com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD; + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), add, null)); + org.junit.jupiter.api.Assertions.assertNull(f.cache.tryMutateLegacyIntegers(java.util.Map.of("X", 1), add, null)); + org.junit.jupiter.api.Assertions.assertEquals(10, stored.get()); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + org.junit.jupiter.api.Assertions.assertEquals(1, f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), add, null).previous().get("X")); + } + } + @Test void olderAsyncSqlitePrimaryUpdateCannotOverwriteNewerPrimary() throws Exception { asyncPrimaryUpdate(false); } + @Test void newerUnrelatedSqliteColumnDoesNotSuppressPrimaryUpdate() throws Exception { asyncPrimaryUpdate(true); } + @Test void failedNewerPrimaryUpdateDoesNotSuppressOlderSuccessfulUpdate() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setString("uuid", replacement, false, true); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_update BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("uuid", f.uuid.toString(), false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_update"); } + task.get().run(); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT uuid FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(replacement, row.getString(1)); } + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void olderSqliteValueWriteCannotRecreateRowAfterNewerPrimaryChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 2, false, true); f.data.setString("uuid", replacement, false); + try { task.get().run(); } catch (IllegalStateException expected) { /* A stale row write may be rejected for retry. */ } + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT count(*) FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } + try (var sql = f.db.prepareStatement("SELECT uuid FROM Users WHERE uuid=?")) { sql.setString(1, replacement); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); } } + } + } + @Test void rejectedStaleSqliteWriteCannotRecreateRowOnRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 2, false, true); f.data.setString("uuid", replacement, false); + try { task.get().run(); } catch (IllegalStateException expected) { } + f.cache.processChanges(); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT count(*) FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedNestedCheckpointRetainsStagedChanges() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_update BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.cache.flushChangesAndRun(() -> org.junit.jupiter.api.Assertions.fail("failed write must prevent action")); + })); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_update"); } + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void nestedCheckpointFlushesPreviouslyStagedWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var observed = new java.util.concurrent.atomic.AtomicInteger(); + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.cache.flushChangesAndRun(() -> { try { observed.set(f.stored()); } catch (Exception e) { throw new IllegalStateException(e); } }); + }); + org.junit.jupiter.api.Assertions.assertEquals(10, observed.get()); + } + } + @Test void nestedCheckpointLeavesLaterWriteForSubsequentFlush() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var observed = new java.util.concurrent.atomic.AtomicInteger(); + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + try { observed.set(f.stored()); } catch (Exception e) { throw new IllegalStateException(e); } + }); + }); + org.junit.jupiter.api.Assertions.assertEquals(10, observed.get()); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void nestedCheckpointPreservesConcurrentLaterWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var data = spy(f.data); when(f.user.getUserData()).thenReturn(data); + org.mockito.Mockito.doAnswer(call -> { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return call.callRealMethod(); }) + .when(data).setValues(any(java.util.HashMap.class)); + var observed = new java.util.concurrent.atomic.AtomicInteger(); var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var checkpoint = pool.submit(() -> f.cache.flushChangesAndRun(() -> { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.cache.flushChangesAndRun(() -> { try { observed.set(f.stored()); } catch (Exception e) { throw new IllegalStateException(e); } }); + })); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + release.countDown(); checkpoint.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, observed.get()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } finally { release.countDown(); pool.shutdownNow(); } + } + } + @Test void retryDoesNotWriteToReloadedStore() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + f.data.setInt("X", 10, false, true); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_update BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + try { task.get().run(); } catch (RuntimeException expected) { } + try { f.cache.processChanges(); } catch (IllegalStateException expected) { } + org.mockito.Mockito.verifyNoInteractions(mysql); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); + } + } + @Test void queuedRetryWaitsForOriginalStoreAfterReload() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + f.data.setInt("X", 10, false, true); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_update BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + try { task.get().run(); } catch (RuntimeException expected) { } + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_update"); } + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.cache.processChanges()); + org.mockito.Mockito.verifyNoInteractions(mysql); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void reloadDuringDefaultBatchDispatchKeepsOriginalStore() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var data = spy(f.data); when(f.user.getUserData()).thenReturn(data); + org.mockito.Mockito.doAnswer(call -> { when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); return call.callRealMethod(); }) + .when(data).setValues(any(java.util.HashMap.class)); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + f.cache.processChanges(); + org.mockito.Mockito.verifyNoInteractions(mysql); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void olderPrimaryOnlyInsertCannotRecreateRowAfterNewerPrimaryChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); var primary = new Column("uuid", new DataValueString(f.uuid.toString())); + var old = f.manager.beginLegacyDirectWrite(f.uuid, java.util.Set.of(com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("uuid")), f.uuid.toString()); + try { + f.data.setString("uuid", replacement, false); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectWrite(f.manager, old, null, () -> f.plugin.getSQLiteUserTable().insert(new java.util.ArrayList<>(java.util.List.of(primary)))); + f.manager.markLegacyDirectWriteSucceeded(old); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT count(*) FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } + } finally { f.manager.endLegacyDirectWrite(old); } + } + } + private void asyncPrimaryUpdate(boolean unrelated) throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setString("uuid", replacement, false, true); org.junit.jupiter.api.Assertions.assertNotNull(task.get()); + if (unrelated) f.data.setInt("X", 10, false); else f.data.setString("uuid", f.uuid.toString(), false); + task.get().run(); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT uuid FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(unrelated ? replacement : f.uuid.toString(), row.getString(1)); + } + } + } + @Test void explicitSqliteBulkDoesNotAlterConfiguredMysqlCacheOrFlush() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.configureLegacyCache(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + org.mockito.Mockito.doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("X")) stored.set(column.getValue().getInt()); return null; }).when(mysql).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq(false)); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + f.data.setValues(com.bencodez.advancedcore.api.user.UserStorage.SQLITE, new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); org.junit.jupiter.api.Assertions.assertEquals(2, f.cache.getIfPresent("X").getInt()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(2, stored.get()); + } + } + @Test void explicitMysqlBulkDoesNotAlterConfiguredSqliteCacheOrFlush() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + org.mockito.Mockito.doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("X")) stored.set(column.getValue().getInt()); return null; }).when(mysql).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq(false)); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + f.data.setValues(com.bencodez.advancedcore.api.user.UserStorage.MYSQL, new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertEquals(10, stored.get()); org.junit.jupiter.api.Assertions.assertEquals(2, f.cache.getIfPresent("X").getInt()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(2, f.stored()); + } + } + @Test void storageReloadDoesNotAttachNewStoreWriteToOldCacheGeneration() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + f.data.setValues(com.bencodez.advancedcore.api.user.UserStorage.MYSQL, new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertEquals(2, f.cache.getIfPresent("X").getInt()); + } + } + @Test void storageReloadScalarDoesNotAlterOldCacheGeneration() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + when(f.plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + f.data.setInt(com.bencodez.advancedcore.api.user.UserStorage.MYSQL, "X", 10, false); + org.junit.jupiter.api.Assertions.assertEquals(2, f.cache.getIfPresent("X").getInt()); + verify(mysql).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.eq("X"), any(com.bencodez.simpleapi.sql.data.DataValue.class)); + } + } + @Test void sharedSqliteRejectsDuplicateOrAmbiguousAliases() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.configureSharedStorage(values -> {}, Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))); + org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, () -> f.cache.tryMutateIntegers(java.util.Map.of("X", 1, "x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + org.junit.jupiter.api.Assertions.assertEquals(5, f.cache.getIfPresent("X").getInt()); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5), "x", new DataValueInt(7)))); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("x")); + org.junit.jupiter.api.Assertions.assertNull(f.cache.tryMutateIntegers(java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))); + f.cache.getCache().put("x", new DataValueInt(7)); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("x")); + } + } + @Test void sharedSqliteAliasRetainsInFlightValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var writes = new java.util.concurrent.atomic.AtomicInteger(); var stored = new java.util.concurrent.atomic.AtomicInteger(); + f.cache.configureSharedStorage(values -> { if (writes.getAndIncrement() == 0) { entered.countDown(); try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } } stored.set(values.get("X").getInt()); }, Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 7), true); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var flush = worker.submit(f.cache::processChangesForSharedRuntime); org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertTrue(f.cache.seedMissingIntegerFromCompleteSnapshot("x")); + var mutation = f.cache.tryMutateIntegers(java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertEquals(7, mutation.previous().get("x")); release.countDown(); flush.get(5, java.util.concurrent.TimeUnit.SECONDS); + f.cache.processChangesForSharedRuntime(); org.junit.jupiter.api.Assertions.assertEquals(8, stored.get()); + } finally { release.countDown(); worker.shutdownNow(); } + } + } + @Test void sharedSqliteKeepsNonAsciiIdentifiersDistinct() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.configureSharedStorage(values -> {}, Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Ä", new DataValueInt(5), "ä", new DataValueInt(7)))); + var result = f.cache.tryMutateIntegers(java.util.Map.of("ä", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertEquals(7, result.previous().get("ä")); org.junit.jupiter.api.Assertions.assertEquals(5, f.cache.getIfPresent("Ä").getInt()); org.junit.jupiter.api.Assertions.assertEquals(8, f.cache.getIfPresent("ä").getInt()); + } + } + @Test void sharedMysqlAmbiguousAliasDoesNotSeedZero() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.configureSharedStorage(values -> {}, Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("x")); + var result = f.cache.tryMutateIntegers(java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNull(result); org.junit.jupiter.api.Assertions.assertNull(f.cache.getIfPresent("x")); org.junit.jupiter.api.Assertions.assertEquals(5, f.cache.getIfPresent("X").getInt()); + org.junit.jupiter.api.Assertions.assertTrue(f.cache.seedMissingIntegerFromCompleteSnapshot("other")); + org.junit.jupiter.api.Assertions.assertEquals(0, f.cache.getIfPresent("other").getInt()); + } + } + @Test void sharedMysqlRejectsArithmeticWhenBothAliasesAlreadyExist() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.configureSharedStorage(values -> {}, Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5), "x", new DataValueInt(0)))); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("x")); + org.junit.jupiter.api.Assertions.assertNull(f.cache.tryMutateIntegers(java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + org.junit.jupiter.api.Assertions.assertEquals(5, f.cache.getIfPresent("X").getInt()); + } + } + @Test void failedExplicitSqliteScalarDoesNotRetryInConfiguredMysql() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.configureLegacyCache(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); when(f.plugin.getMysql()).thenReturn(mysql); + var stored = new java.util.concurrent.atomic.AtomicInteger(1); + org.mockito.Mockito.doAnswer(call -> { for (Column column : call.>getArgument(1)) if (column.getName().equals("X")) stored.set(column.getValue().getInt()); return null; }).when(mysql).update(org.mockito.ArgumentMatchers.eq(f.uuid.toString()), org.mockito.ArgumentMatchers.anyList(), org.mockito.ArgumentMatchers.eq(false)); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setInt(com.bencodez.advancedcore.api.user.UserStorage.SQLITE, "X", 10, false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(2, stored.get()); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void explicitSqliteScalarRetainsCachedCallbackAndDoesNotAlterMysqlCache() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.configureLegacyCache(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { called.set(true); return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.any()); + var table = f.plugin.getSQLiteUserTable(); + org.mockito.Mockito.doAnswer(call -> { org.junit.jupiter.api.Assertions.assertTrue(called.get()); return call.callRealMethod(); }).when(table).update(any(Column.class), org.mockito.ArgumentMatchers.anyList()); + f.data.setInt(com.bencodez.advancedcore.api.user.UserStorage.SQLITE, "X", 10, false); + org.junit.jupiter.api.Assertions.assertTrue(called.get()); org.junit.jupiter.api.Assertions.assertEquals(1, f.cache.getIfPresent("X").getInt()); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + verify(f.users, org.mockito.Mockito.times(1)).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.any()); + } + } + @Test void queuedExplicitOtherStoreRetainsExistingConfiguredQueueBehavior() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.configureLegacyCache(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + f.data.setInt(com.bencodez.advancedcore.api.user.UserStorage.SQLITE, "X", 10, true); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); org.junit.jupiter.api.Assertions.assertEquals(10, f.cache.getIfPresent("X").getInt()); org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); + } + } + @Test void sharedSqliteAliasUsesPersistedBalanceRatherThanZero(@org.junit.jupiter.api.io.TempDir java.nio.file.Path directory) throws Exception { + try (var f = new ReviewSqliteFixture()) { + var backend = new com.bencodez.advancedcore.core.user.storage.sql.SqliteUserBackend(directory, "Users", "Users", com.bencodez.advancedcore.core.user.storage.sql.SqlUserSchema.builder().column("X", "INTEGER", com.bencodez.simpleapi.sql.DataType.INTEGER).build(), com.bencodez.advancedcore.core.user.storage.sql.SqlBackendLogger.NO_OP); + backend.user(f.uuid).writeValues(com.bencodez.advancedcore.api.user.UserStorage.SQLITE, new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(f.manager)); + try { + runtime.populate(f.uuid); org.junit.jupiter.api.Assertions.assertTrue(f.cache.seedMissingIntegerFromCompleteSnapshot("x")); + var result = f.cache.tryMutateIntegers(java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNotNull(result); org.junit.jupiter.api.Assertions.assertEquals(5, result.previous().get("x")); + f.cache.processChangesForSharedRuntime(); + var row = backend.user(f.uuid).readRow(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); org.junit.jupiter.api.Assertions.assertEquals(6, row.stream().filter(column -> column.getName().equalsIgnoreCase("X")).findFirst().orElseThrow().getValue().getInt()); + } finally { runtime.close(); } + } + } + private static final class PausingWriteLock extends java.util.concurrent.locks.ReentrantReadWriteLock { + final java.util.concurrent.CountDownLatch enteringUnlock = new java.util.concurrent.CountDownLatch(1), releaseUnlock = new java.util.concurrent.CountDownLatch(1); + final WriteLock write = new WriteLock(this) { + @Override public void unlock() { + enteringUnlock.countDown(); + try { if (!releaseUnlock.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException(interrupted); } + super.unlock(); + } + }; + @Override public WriteLock writeLock() { return write; } + } + @Test void setterAtCheckpointUnlockIsNotStranded() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var order = new PausingWriteLock(); var field = f.cache.getClass().getDeclaredField("legacyMutationOrder"); field.setAccessible(true); field.set(f.cache, order); + var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var checkpoint = pool.submit(() -> f.cache.flushChangesAndRun(() -> {})); + org.junit.jupiter.api.Assertions.assertTrue(order.enteringUnlock.await(5, java.util.concurrent.TimeUnit.SECONDS)); + var entered = new java.util.concurrent.CountDownLatch(1); + var setter = pool.submit(() -> { entered.countDown(); f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); }); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + try { setter.get(250, java.util.concurrent.TimeUnit.MILLISECONDS); } catch (java.util.concurrent.TimeoutException expected) { } + order.releaseUnlock.countDown(); checkpoint.get(5, java.util.concurrent.TimeUnit.SECONDS); setter.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } finally { order.releaseUnlock.countDown(); pool.shutdownNow(); } + } + } + @Test void reentrantSchedulingCallbackDoesNotStrandCheckpointChange() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (called.compareAndSet(false, true)) f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); return null; }).when(f.plugin).debug(org.mockito.ArgumentMatchers.eq("Schedule changes")); + f.cache.flushChangesAndRun(() -> f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true)); + org.junit.jupiter.api.Assertions.assertTrue(called.get()); verify(f.plugin, org.mockito.Mockito.times(1)).debug("Schedule changes"); f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + } + } + @Test void customProviderRowCannotCertifySharedSnapshotComplete() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var primary = new Column("uuid", new DataValueString(f.uuid.toString())); + var table = new UserTable(f.plugin, "Users", java.util.List.of(primary), primary) { + @Override public void checkColumn(Column column) { } + @Override public java.util.ArrayList getExact(Column column) { var values = super.getExact(column); values.removeIf(value -> value.getName().equals("X")); return values; } + }; + var sqlite = mock(SQLite.class); when(sqlite.getSQLConnection()).thenReturn(f.db); table.setSqLite(sqlite); + var backend = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(f.plugin, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, null, table); + var owner = new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(f.manager); + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner); + try { runtime.populate(f.uuid); org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("X")); } + finally { runtime.close(); } + } + } + @Test void unknownSharedProviderDoesNotAuthorizeMissingDefaults() throws Exception { sharedProviderAuthority(false); } + @Test void optedInSharedProviderAuthorizesMissingDefaults() throws Exception { sharedProviderAuthority(true); } + private void sharedProviderAuthority(boolean authoritative) throws Exception { + try (var f = new ReviewSqliteFixture()) { + var delegate = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(f.plugin, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, null, f.plugin.getSQLiteUserTable()); + var backend = new com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend() { + @Override public com.bencodez.advancedcore.api.user.UserStorage storageType() { return delegate.storageType(); } + @Override public com.bencodez.advancedcore.core.user.storage.SqlUserStorage user(java.util.UUID uuid) { return delegate.user(uuid); } + @Override public java.util.List enumerateUsers() { return delegate.enumerateUsers(); } + @Override public boolean isOpen() { return delegate.isOpen(); } + @Override public void close() { delegate.close(); } + @Override public boolean hasAuthoritativeRowReads() { return authoritative; } + }; + var runtime = new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, new com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner(f.manager)); + try { runtime.populate(f.uuid); org.junit.jupiter.api.Assertions.assertEquals(authoritative, f.cache.seedMissingIntegerFromCompleteSnapshot("missing")); } + finally { runtime.close(); } + } + } + @Test void rejectedPartiallySupersededInsertRetainsFailure() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Y INTEGER"); sql.executeUpdate("DELETE FROM Users"); } + var table = f.plugin.getSQLiteUserTable(); var primary = new Column("uuid", new DataValueString(f.uuid.toString())); + var old = f.manager.beginLegacyDirectWrite(f.uuid, java.util.Set.of(com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X"), com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("Y")), f.uuid.toString()); + var newer = f.manager.beginLegacyDirectWrite(f.uuid, java.util.Set.of(com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X")), f.uuid.toString()); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectWrite(f.manager, newer, null, () -> table.insert(new java.util.ArrayList<>(java.util.List.of(primary, new Column("X", new DataValueInt(10)))))); + f.manager.markLegacyDirectWriteSucceeded(newer); f.manager.endLegacyDirectWrite(newer); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + try { + var failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectWrite(f.manager, old, null, () -> table.insert(new java.util.ArrayList<>(java.util.List.of(primary, new Column("X", new DataValueInt(2)), new Column("Y", new DataValueInt(3))))))); + f.manager.failLegacyDirectWrite(old, null, failure); + } finally { f.manager.endLegacyDirectWrite(old); } + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedOrdinarySqliteLoadCannotAuthorizeDefaultArithmetic() throws Exception { + String uri = "jdbc:sqlite:file:load-" + java.util.UUID.randomUUID() + "?mode=memory&cache=shared"; + try (var db = java.sql.DriverManager.getConnection(uri); var lock = java.sql.DriverManager.getConnection(uri); var f = new ReviewSqliteFixture(db)) { + try (var sql = db.createStatement()) { sql.executeUpdate("PRAGMA busy_timeout=100"); } + f.manager.addKey(new com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt("X")); + f.manager.getUserDataCache().clear(); + try (var sql = lock.createStatement()) { sql.executeUpdate("BEGIN EXCLUSIVE"); } + try { f.manager.cacheUser(f.uuid, null); } finally { try (var sql = lock.createStatement()) { sql.executeUpdate("ROLLBACK"); } } + var add = com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD; + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), add, null)); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); + org.junit.jupiter.api.Assertions.assertTrue(f.manager.populateCompleteLegacyCache(f.uuid)); + var result = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), add, null); + org.junit.jupiter.api.Assertions.assertNotNull(result); org.junit.jupiter.api.Assertions.assertEquals(1, result.previous().get("X")); + } + } + @Test void incompleteLegacyPopulationCannotAuthorizeArithmetic() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.updateCache(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(0)))); + var add = com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD; + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), add, null)); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); + } + } + @Test void sqliteArithmeticUsesExistingAsciiAlias() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var add = com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD; + var first = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("x", 1), add, null); + org.junit.jupiter.api.Assertions.assertNotNull(first); org.junit.jupiter.api.Assertions.assertEquals(1, first.previous().get("x")); + var second = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), add, null); + org.junit.jupiter.api.Assertions.assertNotNull(second); org.junit.jupiter.api.Assertions.assertEquals(2, second.previous().get("X")); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(3, f.stored()); + } + } + @Test void retainedCacheUsesLatestUncachedSqliteAliasWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + when(f.user.isCached()).thenReturn(false); f.data.setInt("x", 20, false); + var result = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNotNull(result); org.junit.jupiter.api.Assertions.assertEquals(20, result.previous().get("X")); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(21, f.stored()); + } + } + @Test void strictSqliteDefaultsDoNotShadowAnExistingAlias() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.addKey(new com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt("x")); + org.junit.jupiter.api.Assertions.assertTrue(f.manager.populateCompleteLegacyCache(f.uuid)); + var result = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNotNull(result); org.junit.jupiter.api.Assertions.assertEquals(1, result.previous().get("x")); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(2, f.stored()); + } + } + @Test void ambiguousSqliteCacheAliasesDoNotAuthorizeArithmetic() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(1), "x", new DataValueInt(7)))); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + } + } + @Test void sqliteArithmeticKeepsNonAsciiColumnsDistinct() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Ä INTEGER"); sql.executeUpdate("ALTER TABLE Users ADD COLUMN ä INTEGER"); sql.executeUpdate("UPDATE Users SET Ä=5, ä=9"); } + f.cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Ä", new DataValueInt(5), "ä", new DataValueInt(9)))); + var result = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("Ä", 1, "ä", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNotNull(result); org.junit.jupiter.api.Assertions.assertEquals(5, result.previous().get("Ä")); org.junit.jupiter.api.Assertions.assertEquals(9, result.previous().get("ä")); + f.cache.processChanges(); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT Ä, ä FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(6, row.getInt(1)); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(2)); } + } + } + @Test void ignoredBulkUuidCannotBlockSuccessfulRetryRetirement() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL, 'reject'); END"); } + var values = new java.util.HashMap(); values.put("uuid", new DataValueString(f.uuid.toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void legacyBatchCapturesGenerationBeforeSelectingValues() throws Exception { + legacyBatchGenerationBoundary(false); + } + @Test void legacyBatchRetriesWhenGenerationChangesAfterCapture() throws Exception { + legacyBatchGenerationBoundary(true); + } + private void legacyBatchGenerationBoundary(boolean afterCapture) throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + class HookMap extends java.util.concurrent.ConcurrentHashMap { + boolean fired; + @Override public Object compute(java.util.UUID key, java.util.function.BiFunction function) { + if (!fired && !afterCapture) { fired = true; f.data.setInt("X", 10, false); } + Object result = super.compute(key, function); + if (!fired && afterCapture) { fired = true; f.data.setInt("X", 10, false); } + return result; + } + } + var field = com.bencodez.advancedcore.api.user.usercache.UserDataManager.class.getDeclaredField("legacyWriteStates"); field.setAccessible(true); field.set(f.manager, new HookMap()); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(10, f.cache.getIfPresent("X").getInt()); + } + } + @Test void failedSqliteDirectRetryDoesNotMergeNonAsciiColumns() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Ä INTEGER"); sql.executeUpdate("ALTER TABLE Users ADD COLUMN ä INTEGER"); } + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); when(f.plugin.getTimer()).thenReturn(timer); + var pending = new java.util.concurrent.atomic.AtomicReference(); + org.mockito.Mockito.doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + f.data.setInt("Ä", 10, false, true); f.data.setInt("ä", 20, true); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL, 'reject'); END"); } + pending.get().run(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.cache.processChanges(); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT Ä, ä FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); org.junit.jupiter.api.Assertions.assertEquals(20, row.getInt(2)); } + } + } + @Test void failedSqliteDirectRetryKeepsNewerCaseAlias() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); when(f.plugin.getTimer()).thenReturn(timer); + var pending = new java.util.concurrent.atomic.AtomicReference(); + org.mockito.Mockito.doAnswer(call -> { pending.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + f.data.setInt("x", 10, false, true); f.data.setInt("X", 20, true); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL, 'reject'); END"); } + pending.get().run(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(20, f.cache.getIfPresent("X").getInt()); + } + } + private static final class ReviewSqliteFixture implements AutoCloseable { + final Connection db; + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + final com.bencodez.advancedcore.api.user.UserManager users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + final com.bencodez.advancedcore.api.user.AdvancedCoreUser user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + final java.util.UUID uuid = java.util.UUID.randomUUID(); + final com.bencodez.advancedcore.api.user.usercache.UserDataManager manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + com.bencodez.advancedcore.api.user.usercache.UserDataCache cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, uuid); + final com.bencodez.advancedcore.api.user.UserData data = new com.bencodez.advancedcore.api.user.UserData(user); + ReviewSqliteFixture() throws Exception { this(java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")); } + ReviewSqliteFixture(Connection connection) throws Exception { + db = connection; + try (var sql = db.createStatement()) { sql.executeUpdate("CREATE TABLE Users(uuid TEXT PRIMARY KEY, X INTEGER)"); sql.executeUpdate("INSERT INTO Users VALUES ('" + uuid + "',1)"); } + var primary = new Column("uuid", new DataValueString(uuid.toString())); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(db); table.setSqLite(sqlite); doNothing().when(table).checkColumn(any(Column.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(uuid.toString()); when(user.getUserData()).thenReturn(data); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + } + void configureLegacyCache(com.bencodez.advancedcore.api.user.UserStorage storage) { + when(plugin.getStorageType()).thenReturn(storage); + cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, uuid); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.getCache()).thenReturn(cache); + } + int stored() throws Exception { try (var sql = db.createStatement(); var row = sql.executeQuery("SELECT X FROM Users")) { row.next(); return row.getInt(1); } } + @Override public void close() throws Exception { manager.getTimer().shutdownNow(); db.close(); } + } + @Test void sqliteAsyncNestedAliasCanDrainBeforeDeferredWrite() throws Exception { sqliteAsyncNestedAlias(false, false, false); } + @Test void sqliteAsyncNestedAliasKeepsCapturedProviderAfterReplacement() throws Exception { sqliteAsyncNestedAlias(true, false, false); } + @Test void sqliteAsyncNestedAliasRetainsFailedDeferredWrite() throws Exception { sqliteAsyncNestedAlias(false, true, false); } + @Test void sqliteAsyncNestedAliasCanDrainOnMissingRow() throws Exception { sqliteAsyncNestedAlias(false, false, true); } + @Test void sqliteConfirmedNestedInsertPreservesOtherBatchColumns() throws Exception { sqliteConfirmedNestedInsert(false); } + @Test void sqliteNestedInsertAfterAdmissionPreservesOtherBatchColumns() throws Exception { sqliteConfirmedNestedInsert(true); } + private void sqliteConfirmedNestedInsert(boolean afterAdmission) throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Y INTEGER"); sql.executeUpdate("DELETE FROM Users"); } + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + var tasks = new java.util.ArrayList(); + when(f.plugin.getTimer()).thenReturn(timer); + org.mockito.Mockito.doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { setInt("x", 7, false, true); super.setValues(values); } + }; + when(f.user.getUserData()).thenReturn(data); + var table = f.plugin.getSQLiteUserTable(); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + if (afterAdmission) { + org.mockito.Mockito.doAnswer(call -> { + if (first.compareAndSet(true, false)) tasks.remove(0).run(); + return call.callRealMethod(); + }).when(table).insert(any()); + } else { + org.mockito.Mockito.doAnswer(call -> { + if (first.compareAndSet(true, false)) { tasks.remove(0).run(); return false; } + return call.callRealMethod(); + }).when(table).containsKey(anyString()); + } + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Y", 30), true); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasChangesToProcess()); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT X, Y FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); + org.junit.jupiter.api.Assertions.assertEquals(7, row.getInt(1)); + org.junit.jupiter.api.Assertions.assertEquals(30, row.getInt(2)); + } + org.junit.jupiter.api.Assertions.assertTrue(tasks.isEmpty()); + } + } + private void sqliteAsyncNestedAlias(boolean replaceProvider, boolean failDeferred, boolean missingRow) throws Exception { + try (var f = new ReviewSqliteFixture()) { + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + var tasks = new java.util.ArrayList(); + when(f.plugin.getTimer()).thenReturn(timer); + org.mockito.Mockito.doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { setInt("x", 7, false, true); super.setValues(values); } + }; + when(f.user.getUserData()).thenReturn(data); + try (var otherDb = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + if (missingRow) try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + try (var sql = otherDb.createStatement()) { sql.executeUpdate("CREATE TABLE Users(uuid TEXT PRIMARY KEY, X INTEGER)"); sql.executeUpdate("INSERT INTO Users VALUES ('" + f.uuid + "',50)"); } + var primary = new Column("uuid", new DataValueString(f.uuid.toString())); + var otherTable = spy(new UserTable(f.plugin, "Users", Collections.singletonList(primary), primary)); + var sqlite = mock(SQLite.class); when(sqlite.getSQLConnection()).thenReturn(otherDb); otherTable.setSqLite(sqlite); + doNothing().when(otherTable).checkColumn(any(Column.class)); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasChangesToProcess()); + org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(1, tasks.size()); + if (replaceProvider) when(f.plugin.getSQLiteUserTable()).thenReturn(otherTable); + if (failDeferred) try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_x BEFORE UPDATE OF X ON Users BEGIN SELECT RAISE(FAIL,'synthetic'); END"); } + tasks.remove(0).run(); + if (failDeferred) { org.junit.jupiter.api.Assertions.assertEquals(20, f.stored()); org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); } + else org.junit.jupiter.api.Assertions.assertEquals(7, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(7, f.cache.getIfPresent("X").getInt()); + try (var sql = otherDb.createStatement(); var row = sql.executeQuery("SELECT X FROM Users")) { row.next(); org.junit.jupiter.api.Assertions.assertEquals(50, row.getInt(1)); } + } finally { while (!tasks.isEmpty()) tasks.remove(0).run(); } + } + } + @Test void sqliteIndependentWriteAfterNestedConfirmationSurvivesBatchRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { + setInt("x", 7, false); + if (first.compareAndSet(true, false)) { + entered.countDown(); + try { if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } + } + super.setValues(values); + } + }; + when(f.user.getUserData()).thenReturn(data); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + var flush = worker.submit(f.cache::processChanges); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + f.data.setInt("X", 99, false); release.countDown(); + flush.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(99, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(99, f.cache.getIfPresent("X").getInt()); + } finally { release.countDown(); worker.shutdownNow(); } + } + } + @Test void sqliteConfirmedNestedFirstRowDoesNotReplayEarlierQueuedValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { setInt("x", 7, false); super.setValues(values); } + }; + when(f.user.getUserData()).thenReturn(data); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(7, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(7, f.cache.getIfPresent("X").getInt()); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasChangesToProcess()); + } + } + @Test void sqliteFailedNestedAliasStaysPendingUntilSuccessfulRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_x BEFORE UPDATE OF X ON Users BEGIN SELECT RAISE(FAIL,'synthetic'); END"); } + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { setInt("x", 7, false); super.setValues(values); } + }; + when(f.user.getUserData()).thenReturn(data); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, f.cache::processChanges); + org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_x"); } + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasChangesToProcess()); + org.junit.jupiter.api.Assertions.assertEquals(7, f.stored()); + } + } + @Test void sqliteConfirmedNestedAliasDrainsWithoutDuplicatePhysicalWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { + sql.executeUpdate("ALTER TABLE Users ADD COLUMN Z INTEGER"); + sql.executeUpdate("CREATE TABLE Audit(n INTEGER)"); + sql.executeUpdate("CREATE TRIGGER audit_x AFTER UPDATE OF X ON Users BEGIN INSERT INTO Audit VALUES(1); END"); + } + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public void setValues(java.util.HashMap values) { setInt("x", 7, false); super.setValues(values); } + }; + when(f.user.getUserData()).thenReturn(data); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 20), true); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Z", 30), true); + f.cache.processChanges(); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT X,Z,(SELECT count(*) FROM Audit) FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); + org.junit.jupiter.api.Assertions.assertEquals(7, row.getInt(1)); + org.junit.jupiter.api.Assertions.assertEquals(30, row.getInt(2)); + org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(3)); + } + org.junit.jupiter.api.Assertions.assertEquals(7, f.cache.getIfPresent("X").getInt()); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.hasChangesToProcess()); + } + } + @Test void sqliteBulkAliasesRespectExistingRowAssignmentOrder() throws Exception { bulkAliases(false, false); } + @Test void sqliteAliasMutationUpdatesCanonicalCacheRead() throws Exception { + try (var f = new ReviewSqliteFixture()) { + org.junit.jupiter.api.Assertions.assertTrue(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("x", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(2, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(2, f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY)); + } + } + @Test void completedSqliteAliasReconcilesOlderSnapshot() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("ALTER TABLE Users ADD COLUMN Other INTEGER"); } + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("x", 2), true); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("Other", 3), true); + long snapshot = f.cache.getSharedSnapshotVersion(); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertTrue(f.cache.tryPublishCompleteLegacySnapshot(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(1))), snapshot)); + org.junit.jupiter.api.Assertions.assertEquals(2, f.cache.getIfPresent("X").getInt()); + } + } + @Test void sqliteAliasBulkWriteUpdatesCanonicalCacheRead() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("x", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(10, f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY)); + } + } + @Test void failedSqliteBulkPrimaryAliasDoesNotPreventRetirementAfterRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.HashMap(); + values.put("UUID", new DataValueString(f.uuid.toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.data.setInt("X", 10, false); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void failedSqliteBulkChangingPrimaryAliasStillRequiresRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.HashMap(); + values.put("UUID", new DataValueString(java.util.UUID.randomUUID().toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedSqliteBulkUppercasePrimaryValueStillRequiresRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.HashMap(); + values.put("UUID", new DataValueString(f.uuid.toString().toUpperCase(java.util.Locale.ROOT))); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedSqliteScalarPrimaryWriteCannotBeAcknowledgedByBulkRetry() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("uuid", java.util.UUID.randomUUID().toString(), false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedPrimaryOnlyInsertCannotBeAcknowledgedAsSuccessful() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); sql.executeUpdate("CREATE TRIGGER reject_insert BEFORE INSERT ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("uuid", java.util.UUID.randomUUID().toString(), false); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void failedBulkInsertTracksActualPrimaryAliasRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); sql.executeUpdate("CREATE TRIGGER reject_insert BEFORE INSERT ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.LinkedHashMap(); + values.put("UUID", new DataValueString(java.util.UUID.randomUUID().toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void filteredConversionCannotCertifyACompleteLegacyRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("UPDATE Users SET X=9"); } + var custom = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public java.util.HashMap convert(java.util.List columns) { + var values = super.convert(columns); values.remove("X"); return values; + } + }; + when(f.user.getUserData()).thenReturn(custom); + org.junit.jupiter.api.Assertions.assertNull(custom.getValuesIfStrictComplete()); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.manager.populateCompleteLegacyCache(f.uuid)); + org.junit.jupiter.api.Assertions.assertEquals(9, f.stored()); + } + } + @Test void filteredConversionCannotCertifyACompleteSharedRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var custom = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public java.util.HashMap convert(java.util.List columns) { + var values = super.convert(columns); values.remove("X"); return values; + } + }; + when(f.user.getUserData()).thenReturn(custom); + var backend = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(f.plugin, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, null, f.plugin.getSQLiteUserTable()); + f.manager.bindSharedSqlBackend(backend, Runnable::run); f.cache.configureSharedStorage(values -> {}, Runnable::run); + f.manager.cacheUser(f.uuid, null); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("X")); + } + } + @Test void filteredRowReaderCannotCertifyACompleteSharedRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var custom = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public java.util.List getSQLiteRow() { var columns = new java.util.ArrayList<>(super.getSQLiteRow()); columns.removeIf(column -> column.getName().equals("X")); return columns; } + }; + when(f.user.getUserData()).thenReturn(custom); + var backend = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(f.plugin, com.bencodez.advancedcore.api.user.UserStorage.SQLITE, null, f.plugin.getSQLiteUserTable()); + f.manager.bindSharedSqlBackend(backend, Runnable::run); f.cache.configureSharedStorage(values -> {}, Runnable::run); f.manager.cacheUser(f.uuid, null); + org.junit.jupiter.api.Assertions.assertFalse(f.cache.seedMissingIntegerFromCompleteSnapshot("X")); + } + } + @Test void arithmeticIsNotAdmittedBehindPendingLegacyCheckpoint() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var field = f.cache.getClass().getDeclaredField("legacyMutationOrder"); field.setAccessible(true); var order = (java.util.concurrent.locks.ReentrantReadWriteLock) field.get(f.cache); + var pendingField = f.cache.getClass().getDeclaredField("legacyCheckpointsPending"); pendingField.setAccessible(true); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); order.readLock().lock(); + try { + var checkpoint = pool.submit(() -> f.cache.flushChangesAndRun(() -> f.cache.updateCache(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(5)))))); + long until = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5); boolean pending = false; + while (System.nanoTime() < until) { synchronized (f.cache) { pending = pendingField.getInt(f.cache) > 0; } if (pending) break; Thread.yield(); } + org.junit.jupiter.api.Assertions.assertTrue(pending); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 10), true); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + order.readLock().unlock(); checkpoint.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertTrue(f.manager.populateCompleteLegacyCache(f.uuid)); + var first = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + var second = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertEquals(10, first.previous().get("X")); org.junit.jupiter.api.Assertions.assertEquals(11, second.previous().get("X")); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(12, f.stored()); + } finally { if (order.getReadHoldCount() > 0) order.readLock().unlock(); pool.shutdownNow(); } + } + } + @Test void prepareFailureTracksEffectivePrimaryAlias() throws Exception { prepareFailurePrimaryAlias(false); } + @Test void prepareFailureRetainsChangedEffectivePrimaryAlias() throws Exception { prepareFailurePrimaryAlias(true); } + private void prepareFailurePrimaryAlias(boolean changed) throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + var wrapped = mock(Connection.class); when(wrapped.unwrap(org.sqlite.SQLiteConnection.class)).thenReturn((org.sqlite.SQLiteConnection) f.db); + var fail = new java.util.concurrent.atomic.AtomicBoolean(true); + when(wrapped.prepareStatement(anyString())).thenAnswer(call -> { String sql = call.getArgument(0); if (sql.startsWith("UPDATE") && fail.getAndSet(false)) throw new java.sql.SQLException("synthetic prepare failure"); return f.db.prepareStatement(sql); }); + var sqlite = mock(SQLite.class); when(sqlite.getSQLConnection()).thenReturn(wrapped); f.plugin.getSQLiteUserTable().setSqLite(sqlite); + String other = java.util.UUID.randomUUID().toString(); + var values = new java.util.LinkedHashMap(); values.put("UUID", new DataValueString(changed ? f.uuid.toString() : other)); values.put("uUiD", new DataValueString(changed ? other : f.uuid.toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); f.data.setInt("X", 10, false); + if (changed) org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + else f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void olderPrimaryOnlyInsertDoesNotReplaceNewerRowValues() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + var table = f.plugin.getSQLiteUserTable(); var primary = new Column("uuid", new DataValueString(f.uuid.toString())); + var old = f.manager.beginLegacyDirectWrite(f.uuid, java.util.Set.of(com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("uuid")), f.uuid.toString()); + var newer = f.manager.beginLegacyDirectWrite(f.uuid, java.util.Set.of(com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X")), f.uuid.toString()); + try { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectWrite(f.manager, newer, null, () -> table.insert(new java.util.ArrayList<>(java.util.List.of(primary, new Column("X", new DataValueInt(10)))))); + f.manager.markLegacyDirectWriteSucceeded(newer); f.manager.endLegacyDirectWrite(newer); + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectWrite(f.manager, old, null, () -> table.insert(new java.util.ArrayList<>(java.util.List.of(primary, new Column("uuid", new DataValueString(f.uuid.toString())))))); + f.manager.markLegacyDirectWriteSucceeded(old); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } finally { f.manager.endLegacyDirectWrite(old); } + } + } + @Test void sharedBindCannotOvertakeLegacySnapshotPublication() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var cache = spy(f.cache); f.manager.getUserDataCache().put(f.uuid, cache); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + org.mockito.Mockito.doAnswer(call -> { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return call.callRealMethod(); }).when(cache).tryPublishCompleteLegacySnapshot(any(), org.mockito.ArgumentMatchers.anyLong()); + var pool = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var load = pool.submit(() -> f.manager.populateCompleteLegacyCache(f.uuid)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.storageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.manager.bindSharedSqlBackend(backend, Runnable::run)); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, f.manager::beginSharedBindingTransition); + release.countDown(); org.junit.jupiter.api.Assertions.assertTrue(load.get(5, java.util.concurrent.TimeUnit.SECONDS)); + f.manager.bindSharedSqlBackend(backend, Runnable::run); + } finally { release.countDown(); pool.shutdownNow(); } + } + } + @Test void reentrantNotificationCanFlushQueuedLegacyWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (called.compareAndSet(false, true)) f.cache.processChanges(); return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.any()); + f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertTrue(called.get()); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void reentrantNotificationDoesNotExemptAnotherDirectWriter() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + var other = f.manager.beginLegacyDirectWrite(f.uuid, com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X")); + try { + var called = new java.util.concurrent.atomic.AtomicBoolean(); + org.mockito.Mockito.doAnswer(call -> { if (called.compareAndSet(false, true)) f.cache.processChanges(); return null; }).when(f.users).onChange(org.mockito.ArgumentMatchers.eq(f.user), org.mockito.ArgumentMatchers.any()); + org.junit.jupiter.api.Assertions.assertThrows(com.bencodez.advancedcore.api.user.usercache.UserDataCache.StaleLegacyBatchException.class, () -> f.data.setInt("X", 10, false)); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); + } finally { f.manager.endLegacyDirectWrite(other); } + } + } + @Test void primaryOnlyInsertDoesNotPublishWrongAlias() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + String other = java.util.UUID.randomUUID().toString(); + var values = new java.util.LinkedHashMap(); values.put("UUID", new DataValueString(f.uuid.toString())); values.put("uUiD", new DataValueString(other)); + f.data.setValues(values); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT uuid FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(f.uuid.toString(), row.getString(1)); } + org.junit.jupiter.api.Assertions.assertFalse(f.cache.snapshot().values().stream().anyMatch(value -> value != null && value.isString() && other.equals(value.getString()))); + } + } + @Test void failedPrimaryOnlyInsertDoesNotTrackIneffectiveAlias() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); sql.executeUpdate("CREATE TRIGGER reject_insert BEFORE INSERT ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.LinkedHashMap(); values.put("UUID", new DataValueString(f.uuid.toString())); values.put("uUiD", new DataValueString(java.util.UUID.randomUUID().toString())); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_insert"); } + f.data.setInt("X", 10, false); f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void sqliteCaseDistinctRowsDoNotSupersedeEachOther() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String lower = f.uuid.toString(), upper = lower.toUpperCase(java.util.Locale.ROOT); + org.junit.jupiter.api.Assertions.assertNotEquals(lower, upper); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + upper + "',1)"); } + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + var other = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(other.getPlugin()).thenReturn(f.plugin); when(other.getUUID()).thenReturn(upper); when(other.isCached()).thenReturn(false); + var otherData = new com.bencodez.advancedcore.api.user.UserData(other); + f.data.setInt("X", 2, false, true); org.junit.jupiter.api.Assertions.assertNotNull(task.get()); + otherData.setInt("X", 10, false); task.get().run(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, lower); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(2, row.getInt(1)); } + sql.setString(1, upper); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } + } + } + @Test void sqliteCaseDistinctCachedRowsDoNotShareValues() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String upper = f.uuid.toString().toUpperCase(java.util.Locale.ROOT); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + upper + "',1)"); } + var other = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(other.getPlugin()).thenReturn(f.plugin); when(other.getUUID()).thenReturn(upper); when(other.isCached()).thenReturn(true); when(other.getCache()).thenReturn(f.cache); + new com.bencodez.advancedcore.api.user.UserData(other).setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertEquals(1, f.cache.getIfPresent("X").getInt()); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } + } + } + } + @Test void sqliteCaseDistinctDirectStringDoesNotReplaceQueuedCanonicalValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String upper = f.uuid.toString().toUpperCase(java.util.Locale.ROOT); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + upper + "',1)"); } + f.data.setInt("X", 2, true); + var other = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(other.getPlugin()).thenReturn(f.plugin); when(other.getUUID()).thenReturn(upper); when(other.isCached()).thenReturn(true); when(other.getCache()).thenReturn(f.cache); + new com.bencodez.advancedcore.api.user.UserData(other).setString("X", "10", false); + org.junit.jupiter.api.Assertions.assertEquals(2, f.cache.getIfPresent("X").getInt()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(2, f.stored()); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, upper); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } + } + } + @Test void olderScalarPublicationCannotReplaceNewerCommittedValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + when(f.user.isCached()).thenAnswer(call -> { if (first.compareAndSet(true, false)) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return true; }); + var executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var older = executor.submit(() -> f.data.setInt("X", 2, false)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + f.data.setInt("X", 10, false); release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + org.junit.jupiter.api.Assertions.assertEquals(10, f.cache.getIfPresent("X").getInt()); + } finally { release.countDown(); executor.shutdownNow(); } + } + } + @Test void failedNewerScalarRemainsRetryableAfterOlderWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + when(f.user.isCached()).thenAnswer(call -> { if (first.compareAndSet(true, false)) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return true; }); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_ten BEFORE UPDATE ON Users WHEN NEW.X=10 BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var older = executor.submit(() -> f.data.setInt("X", 2, false)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + f.data.setInt("X", 10, false); release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_ten"); } + org.junit.jupiter.api.Assertions.assertEquals(10, f.cache.getIfPresent("X").getInt()); + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } finally { release.countDown(); executor.shutdownNow(); } + } + } + @Test void asyncWriteFailureTracksExecutionTimeIdentity() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + other + "',1)"); sql.executeUpdate("CREATE TRIGGER reject_other BEFORE UPDATE ON Users WHEN OLD.uuid='" + other + "' BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 10, false, true); org.junit.jupiter.api.Assertions.assertNotNull(task.get()); identity.set(other); task.get().run(); + var failed = com.bencodez.advancedcore.api.user.usercache.UserDataManager.class.getDeclaredField("legacyUnrecoveredWrites"); failed.setAccessible(true); + org.junit.jupiter.api.Assertions.assertFalse(((java.util.Map) failed.get(f.manager)).isEmpty(), "execution-row failure must be retained"); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + } + } + @Test void asyncIdentityRebindRemainsAdmittedDuringRetirement() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + other + "',1)"); } + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 10, false, true); + var retirement = f.manager.clearCacheAsyncCompletion().toCompletableFuture(); org.junit.jupiter.api.Assertions.assertFalse(retirement.isDone()); + identity.set(other); task.get().run(); retirement.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { sql.setString(1, other); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } } + } + } + @Test void asyncRebindDoesNotOverwriteOriginalQueuedValue() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + other + "',1)"); } + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + f.data.setInt("X", 10, false, true); identity.set(other); task.get().run(); identity.set(f.uuid.toString()); f.cache.processChanges(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(2, row.getInt(1)); } + sql.setString(1, other); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } + } + } + @Test void queuedMutationCannotRecreateRowAfterPrimaryMove() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + org.junit.jupiter.api.Assertions.assertTrue(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + f.data.setString("uuid", other, false); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, f.cache::processChanges); + try (var sql = f.db.prepareStatement("SELECT count(*) FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { row.next(); org.junit.jupiter.api.Assertions.assertEquals(0, row.getInt(1)); } + } + org.junit.jupiter.api.Assertions.assertTrue(f.cache.hasChangesToProcess()); + } + } + @Test void movedSqliteRowCannotSpendOldSourceBalance() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); f.data.setString("uuid", other, false); + org.junit.jupiter.api.Assertions.assertNull(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null)); + try (var sql = f.db.prepareStatement("SELECT count(*) FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { row.next(); org.junit.jupiter.api.Assertions.assertEquals(0, row.getInt(1)); } + } + org.junit.jupiter.api.Assertions.assertTrue(f.manager.populateCompleteLegacyCache(f.uuid)); + var afterRefresh = f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null); + org.junit.jupiter.api.Assertions.assertNotNull(afterRefresh); org.junit.jupiter.api.Assertions.assertTrue(afterRefresh.applied()); + f.cache.processChanges(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } + sql.setString(1, other); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(1, row.getInt(1)); } + } + } + } + @Test void primaryValueOnFirstInsertDoesNotFenceQueuedRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + f.data.setString("uuid", java.util.UUID.randomUUID().toString(), false); + f.cache.processChanges(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(2, row.getInt(1)); } + } + } + } + @Test void failedPrimaryMoveDoesNotFenceQueuedMutation() throws Exception { + try (var f = new ReviewSqliteFixture()) { + org.junit.jupiter.api.Assertions.assertTrue(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_move BEFORE UPDATE ON Users WHEN NEW.uuid != OLD.uuid BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("uuid", java.util.UUID.randomUUID().toString(), false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_move"); } + f.cache.processChanges(); org.junit.jupiter.api.Assertions.assertEquals(2, f.stored()); + } + } + @Test void asyncRebindDoesNotUndoNewerQueuedMutation() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + other + "',1)"); } + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 10, false, true); + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 3), true); + identity.set(other); task.get().run(); identity.set(f.uuid.toString()); f.cache.processChanges(); + try (var sql = f.db.prepareStatement("SELECT X FROM Users WHERE uuid=?")) { + sql.setString(1, f.uuid.toString()); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(3, row.getInt(1)); } + sql.setString(1, other); try (var row = sql.executeQuery()) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } + } + } + @Test void asyncRebindSuccessDoesNotClearOriginalRowFailure() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + other + "',1)"); sql.executeUpdate("CREATE TRIGGER reject_first BEFORE UPDATE ON Users WHEN OLD.uuid='" + f.uuid + "' BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + f.data.setInt("X", 3, false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_first"); } + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 10, false, true); identity.set(other); task.get().run(); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + identity.set(f.uuid.toString()); f.data.setInt("X", 3, false); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void asyncReboundRowFailureRequiresRetryOnThatRow() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String other = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("INSERT INTO Users VALUES ('" + other + "',1)"); sql.executeUpdate("CREATE TRIGGER reject_other BEFORE UPDATE ON Users WHEN OLD.uuid='" + other + "' BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + var identity = new java.util.concurrent.atomic.AtomicReference<>(f.uuid.toString()); when(f.user.getUUID()).thenAnswer(call -> identity.get()); + var task = new java.util.concurrent.atomic.AtomicReference(); var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + org.mockito.Mockito.doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); when(f.plugin.getTimer()).thenReturn(timer); + f.data.setInt("X", 10, false, true); identity.set(other); task.get().run(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_other"); } + identity.set(f.uuid.toString()); f.data.setInt("X", 10, false); + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS)); + identity.set(other); f.data.setInt("X", 10, false); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void failedBulkWithUnchangedEffectivePrimaryCanRetryWithoutPrimaryAlias() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.LinkedHashMap(); + values.put("UUID", new DataValueString(java.util.UUID.randomUUID().toString())); values.put("uUiD", new DataValueString(f.uuid.toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + var retry = new java.util.LinkedHashMap(); retry.put("uUiD", new DataValueString(f.uuid.toString())); retry.put("X", new DataValueInt(10)); + f.data.setValues(retry); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void failedBulkInsertUsesFirstPrimaryAliasForFailureTracking() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); when(f.user.isCached()).thenReturn(false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); sql.executeUpdate("CREATE TRIGGER reject_insert BEFORE INSERT ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + var values = new java.util.LinkedHashMap(); + values.put("UUID", new DataValueString(f.uuid.toString())); values.put("uUiD", new DataValueString(java.util.UUID.randomUUID().toString())); values.put("X", new DataValueInt(10)); + f.data.setValues(values); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_insert"); } + f.data.setInt("X", 10, false); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertEquals(10, f.stored()); + } + } + @Test void failedSqliteScalarPrimaryWriteCanBeRetriedExplicitly() throws Exception { + try (var f = new ReviewSqliteFixture()) { + String replacement = java.util.UUID.randomUUID().toString(); + try (var sql = f.db.createStatement()) { sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL,'reject'); END"); } + f.data.setString("uuid", replacement, false); + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + f.data.setString("uuid", replacement, false); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT uuid FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(replacement, row.getString(1)); + } + } + } + @Test void queuedSetterAfterBulkInvalidationDoesNotReadStorageOnCaller() throws Exception { + try (var f = new ReviewSqliteFixture()) { + when(f.plugin.getOptions()).thenReturn(new com.bencodez.advancedcore.AdvancedCoreConfigOptions()); + when(f.user.getCache()).thenAnswer(call -> f.manager.getCache(f.uuid)); + var table = f.plugin.getSQLiteUserTable(); var reads = new java.util.concurrent.atomic.AtomicInteger(); Thread caller = Thread.currentThread(); + org.mockito.Mockito.doAnswer(call -> { if (Thread.currentThread() == caller) reads.incrementAndGet(); return call.callRealMethod(); }).when(table).getExact(any(Column.class)); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + f.data.setInt("X", 11, true); + org.junit.jupiter.api.Assertions.assertEquals(0, reads.get()); + } + } + @Test void cacheOnlyReadAfterBulkWriteDoesNotQuerySqlOnCaller() throws Exception { + try (var f = new ReviewSqliteFixture()) { + when(f.plugin.getOptions()).thenReturn(new com.bencodez.advancedcore.AdvancedCoreConfigOptions()); + when(f.user.getCache()).thenAnswer(call -> f.manager.getCache(f.uuid)); + org.mockito.Mockito.doAnswer(call -> { f.manager.cacheUserIfNeeded(f.uuid); return null; }).when(f.user).cacheIfNeeded(); + var table = f.plugin.getSQLiteUserTable(); var reads = new java.util.concurrent.atomic.AtomicInteger(); Thread caller = Thread.currentThread(); + org.mockito.Mockito.doAnswer(call -> { if (Thread.currentThread() == caller) reads.incrementAndGet(); return call.callRealMethod(); }).when(table).getExact(any(Column.class)); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertEquals(10, f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY)); + org.junit.jupiter.api.Assertions.assertEquals(0, reads.get()); + } + } + @Test void noWaitHotReadAfterBulkWriteDoesNotQuerySqlOnCaller() throws Exception { + try (var f = new ReviewSqliteFixture()) { + when(f.plugin.getOptions()).thenReturn(new com.bencodez.advancedcore.AdvancedCoreConfigOptions()); + when(f.user.getCache()).thenAnswer(call -> f.manager.getCache(f.uuid)); + org.mockito.Mockito.doAnswer(call -> { f.manager.cacheUserIfNeeded(f.uuid); return null; }).when(f.user).cacheIfNeeded(); + var table = f.plugin.getSQLiteUserTable(); var reads = new java.util.concurrent.atomic.AtomicInteger(); Thread caller = Thread.currentThread(); + org.mockito.Mockito.doAnswer(call -> { if (Thread.currentThread() == caller) reads.incrementAndGet(); return call.callRealMethod(); }).when(table).getExact(any(Column.class)); + org.mockito.Mockito.doAnswer(call -> { if (Thread.currentThread() == caller) reads.incrementAndGet(); return call.callRealMethod(); }).when(table).getExactStrict(any(Column.class)); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertEquals(10, f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_WAIT)); + org.junit.jupiter.api.Assertions.assertEquals(0, reads.get()); + } + } + @Test void invalidatedNoWaitReadDoesNotWaitForBlockedRefresh() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var table = f.plugin.getSQLiteUserTable(); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var finished = new java.util.concurrent.CountDownLatch(1); + org.mockito.Mockito.doAnswer(call -> { try { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return call.callRealMethod(); } finally { finished.countDown(); } }).when(table).getExactStrict(any(Column.class)); + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + var executor = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var read = executor.submit(() -> f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_WAIT)); + org.junit.jupiter.api.Assertions.assertEquals(10, read.get(1, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS)); + } finally { release.countDown(); finished.await(5, java.util.concurrent.TimeUnit.SECONDS); executor.shutdownNow(); } + } + } + @Test void cacheOnlyAndNoDbLookupNeverScheduleAnInvalidatedRefresh() throws Exception { + for (var mode : java.util.List.of(com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY, + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_DB_LOOKUP)) { + try (var f = new ReviewSqliteFixture()) { + f.cache.invalidateStorageSnapshot("X"); + f.data.getInt("X", 77, mode); + f.manager.getTimer().submit(() -> {}).get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(f.plugin.getSQLiteUserTable(), org.mockito.Mockito.never()).getExactStrict(any(Column.class)); + verify(f.user, org.mockito.Mockito.never()).getCache(); + } + } + } + @Test void noStorageModeDoesNotRefreshWhenInvalidationRacesCacheLookup() throws Exception { + for (var mode : java.util.List.of(com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY, + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_DB_LOOKUP)) { + try (var f = new ReviewSqliteFixture()) { + var cache = spy(f.cache); + f.manager.getUserDataCache().put(f.uuid, cache); + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + org.mockito.Mockito.doAnswer(call -> { + boolean published = (Boolean) call.callRealMethod(); + if (first.compareAndSet(true, false)) cache.invalidateStorageSnapshot("X"); + return published; + }).when(cache).hasPublishedStorageSnapshot(); + when(f.user.getCache()).thenAnswer(call -> f.manager.getCache(f.uuid)); + f.data.getInt("X", 77, mode); + f.manager.getTimer().submit(() -> {}).get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(f.plugin.getSQLiteUserTable(), org.mockito.Mockito.never()).getExactStrict(any(Column.class)); + verify(f.user, org.mockito.Mockito.never()).getCache(); + } + } + } + @Test void noStorageModeDoesNotCreateAMissingCache() throws Exception { + for (var mode : java.util.List.of(com.bencodez.advancedcore.api.user.UserDataFetchMode.CACHE_ONLY, + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_DB_LOOKUP)) { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().remove(f.uuid); + when(f.user.getCache()).thenAnswer(call -> f.manager.getCache(f.uuid)); + org.junit.jupiter.api.Assertions.assertEquals(77, f.data.getInt("X", 77, mode)); + verify(f.user, org.mockito.Mockito.never()).getCache(); + verify(f.plugin.getSQLiteUserTable(), org.mockito.Mockito.never()).getExactStrict(any(Column.class)); + org.junit.jupiter.api.Assertions.assertNull(f.manager.getUserDataCache().get(f.uuid)); + } + } + } + @Test void defaultReadRetriesFailedCustomColdPopulation() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().remove(f.uuid); + when(f.plugin.getOptions()).thenReturn(new com.bencodez.advancedcore.AdvancedCoreConfigOptions()); + when(f.user.getCache()).thenAnswer(call -> f.manager.getCache(f.uuid)); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + var data = new com.bencodez.advancedcore.api.user.UserData(f.user) { + @Override public java.util.ArrayList getKeys() { return new java.util.ArrayList<>(); } + @Override public java.util.HashMap getValues() { + if (calls.getAndIncrement() == 0) throw new IllegalStateException("first read failed"); + return new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(7))); + } + }; + when(f.user.getUserData()).thenReturn(data); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> f.manager.cacheUser(f.uuid, null)); + org.junit.jupiter.api.Assertions.assertEquals(7, data.getInt("X", 77, com.bencodez.advancedcore.api.user.UserDataFetchMode.DEFAULT)); + org.junit.jupiter.api.Assertions.assertEquals(2, calls.get()); + } + } + @Test void directWritesNotifyOnceWhenEmptyCacheBecomesPopulated() throws Exception { + for (boolean text : new boolean[] { false, true }) for (boolean async : new boolean[] { false, true }) { + try (var f = new ReviewSqliteFixture()) { + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, f.uuid); + f.manager.getUserDataCache().put(f.uuid, cache); + when(f.user.isCached()).thenAnswer(call -> f.manager.isCached(f.uuid)); + var tasks = new java.util.ArrayList(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(f.plugin.getTimer()).thenReturn(timer); + org.mockito.Mockito.doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + if (text) f.data.setString("X", "4", false, async); else f.data.setInt("X", 4, false, async); + while (!tasks.isEmpty()) tasks.remove(0).run(); + verify(f.users, org.mockito.Mockito.times(1)).onChange(f.user, "X"); + } + } + } + @Test void uncachedDirectWriteNotificationCanAwaitRetirement() throws Exception { + for (boolean text : new boolean[] { false, true }) for (boolean async : new boolean[] { false, true }) { + try (var f = new ReviewSqliteFixture()) { + f.manager.getUserDataCache().clear(); + when(f.user.isCached()).thenReturn(false); + var tasks = new java.util.ArrayList(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(f.plugin.getTimer()).thenReturn(timer); + org.mockito.Mockito.doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + org.mockito.Mockito.doAnswer(call -> { + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(1, java.util.concurrent.TimeUnit.SECONDS); + return null; + }).when(f.users).onChange(f.user, "X"); + if (text) f.data.setString("X", "9", false, async); else f.data.setInt("X", 9, false, async); + while (!tasks.isEmpty()) tasks.remove(0).run(); + verify(f.users, org.mockito.Mockito.times(1)).onChange(f.user, "X"); + org.junit.jupiter.api.Assertions.assertEquals(9, f.stored()); + } + } + } + @Test void cachedDirectWriteNotificationRejectsItsOwnRetirementAndAllowsLaterRetry() throws Exception { + for (boolean text : new boolean[] { false, true }) for (boolean async : new boolean[] { false, true }) { + try (var f = new ReviewSqliteFixture()) { + var tasks = new java.util.ArrayList(); + var timer = mock(java.util.concurrent.ScheduledExecutorService.class); + when(f.plugin.getTimer()).thenReturn(timer); + org.mockito.Mockito.doAnswer(call -> { tasks.add(call.getArgument(0)); return null; }).when(timer).execute(any(Runnable.class)); + org.mockito.Mockito.doAnswer(call -> { + var failure = org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.ExecutionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(1, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertInstanceOf(IllegalStateException.class, failure.getCause()); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored(), "notification remains before SQL"); + return null; + }).when(f.users).onChange(f.user, "X"); + if (text) f.data.setString("X", "9", false, async); else f.data.setInt("X", 9, false, async); + while (!tasks.isEmpty()) tasks.remove(0).run(); + org.junit.jupiter.api.Assertions.assertEquals(9, f.stored()); + verify(f.users, org.mockito.Mockito.times(1)).onChange(f.user, "X"); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + } + @Test void cachedQueuedAsyncNotificationCanAwaitRetirement() throws Exception { + for (boolean text : new boolean[] { false, true }) try (var f = new ReviewSqliteFixture()) { + var first = new java.util.concurrent.atomic.AtomicBoolean(true); + org.mockito.Mockito.doAnswer(call -> { + if (first.compareAndSet(true, false)) f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(1, java.util.concurrent.TimeUnit.SECONDS); + return null; + }).when(f.users).onChange(f.user, "X"); + if (text) f.data.setString("X", "9", true, true); else f.data.setInt("X", 9, true, true); + org.junit.jupiter.api.Assertions.assertEquals(9, f.stored()); + } + } + @Test void nestedOtherManagerCallbackCannotHideLiveRetirementAdmission() throws Exception { + try (var outer = new ReviewSqliteFixture(); var inner = new ReviewSqliteFixture()) { + var outerWrite = outer.manager.beginLegacyDirectWrite(outer.uuid, com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X")); + var innerWrite = inner.manager.beginLegacyDirectWrite(inner.uuid, com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X")); + try { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectCallback(outer.manager, outerWrite, () -> + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectCallback(inner.manager, innerWrite, () -> { + var failure = org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.CompletionException.class, + () -> outer.manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + org.junit.jupiter.api.Assertions.assertInstanceOf(IllegalStateException.class, failure.getCause()); + })); + } finally { inner.manager.endLegacyDirectWrite(innerWrite); outer.manager.endLegacyDirectWrite(outerWrite); } + outer.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void reentrantRejectionDoesNotCancelAnExistingRetirement() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var admission = f.manager.beginLegacyDirectWrite(f.uuid, com.bencodez.advancedcore.api.user.usercache.UserDataCache.sqliteDirectKey("X")); + var observer = f.manager.clearCacheAsyncCompletion().toCompletableFuture(); + var cancelled = f.manager.clearCacheAsyncCompletion().toCompletableFuture(); + org.junit.jupiter.api.Assertions.assertTrue(cancelled.cancel(false)); + try { + com.bencodez.advancedcore.api.user.usercache.UserDataCache.runWithLegacyDirectCallback(f.manager, admission, () -> { + var error = org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.CompletionException.class, + () -> f.manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + org.junit.jupiter.api.Assertions.assertInstanceOf(IllegalStateException.class, error.getCause()); + }); + org.junit.jupiter.api.Assertions.assertFalse(observer.isDone()); + } finally { f.manager.endLegacyDirectWrite(admission); } + observer.get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void uncaughtReentrantRejectionReleasesAdmissionAndRetainsFailedWrite() throws Exception { + try (var f = new ReviewSqliteFixture()) { + org.mockito.Mockito.doAnswer(call -> { + f.manager.clearCacheAsyncCompletion().toCompletableFuture().join(); + return null; + }).when(f.users).onChange(f.user, "X"); + var failure = org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.CompletionException.class, + () -> f.data.setInt("X", 9, false)); + org.junit.jupiter.api.Assertions.assertInstanceOf(IllegalStateException.class, failure.getCause()); + org.junit.jupiter.api.Assertions.assertEquals(1, f.stored()); + org.mockito.Mockito.doNothing().when(f.users).onChange(f.user, "X"); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(9, f.stored()); + f.manager.clearCacheAsyncCompletion().toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + } + } + @Test void deferredLegacyRefreshDoesNotReviveReplacedGeneration() throws Exception { + try (var f = new ReviewSqliteFixture()) { + f.data.setValues(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(10)))); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + f.manager.getTimer().execute(() -> { entered.countDown(); try { release.await(5, java.util.concurrent.TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + org.junit.jupiter.api.Assertions.assertTrue(entered.await(2, java.util.concurrent.TimeUnit.SECONDS)); + org.junit.jupiter.api.Assertions.assertEquals(10, f.data.getInt("X", 0, com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_WAIT)); + var replacement = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(f.manager, f.uuid); + replacement.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("X", new DataValueInt(77)))); + f.manager.getUserDataCache().put(f.uuid, replacement); + release.countDown(); f.manager.getTimer().submit(() -> {}).get(5, java.util.concurrent.TimeUnit.SECONDS); + org.junit.jupiter.api.Assertions.assertSame(replacement, f.manager.getUserDataCache().get(f.uuid)); + org.junit.jupiter.api.Assertions.assertEquals(77, replacement.getIfPresent("X").getInt()); + verify(f.plugin.getSQLiteUserTable(), org.mockito.Mockito.never()).getExactStrict(any(Column.class)); + } finally { release.countDown(); } + } + } + @Test void sqliteBulkAliasesRespectMissingRowAssignmentOrder() throws Exception { bulkAliases(true, false); } + @Test void sqliteBulkAliasesRespectInsertConflictAssignmentOrder() throws Exception { bulkAliases(false, true); } + private void bulkAliases(boolean missing, boolean conflict) throws Exception { + try (var f = new ReviewSqliteFixture()) { + if (missing) try (var sql = f.db.createStatement()) { sql.executeUpdate("DELETE FROM Users"); } + if (conflict) { var table = f.plugin.getSQLiteUserTable(); doReturn(false).doCallRealMethod().when(table).containsKey(anyString()); } + f.cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("X", 2), true); + var values = new java.util.HashMap(); + values.put("X", new DataValueInt(10)); values.put("x", new DataValueInt(20)); + f.data.setValues(values); + int written = f.stored(); + org.junit.jupiter.api.Assertions.assertEquals(missing || conflict ? 10 : 20, written); + org.junit.jupiter.api.Assertions.assertTrue(f.manager.populateCompleteLegacyCache(f.uuid)); + org.junit.jupiter.api.Assertions.assertTrue(f.manager.tryMutateLegacyIntegers(f.uuid, java.util.Map.of("X", 1), com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + f.cache.processChanges(); + org.junit.jupiter.api.Assertions.assertEquals(written + 1, f.stored()); + } + } + @Test void guardedSqliteBulkInsertSupportsThousandColumns() throws Exception { + try (var f = new ReviewSqliteFixture()) { + var ddl = new StringBuilder("CREATE TABLE Users(uuid TEXT PRIMARY KEY"); + var values = new java.util.HashMap(); + for (int i = 0; i < 1000; i++) { ddl.append(", c").append(i).append(" INTEGER"); values.put("c" + i, new DataValueInt(i)); } + try (var sql = f.db.createStatement()) { sql.executeUpdate("DROP TABLE Users"); sql.executeUpdate(ddl.append(")").toString()); } + f.data.setValues(values); + try (var sql = f.db.createStatement(); var row = sql.executeQuery("SELECT c0,c999 FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); + org.junit.jupiter.api.Assertions.assertEquals(0, row.getInt(1)); org.junit.jupiter.api.Assertions.assertEquals(999, row.getInt(2)); + } + } + } + @Test void sqliteBulkSupersedesOlderQueuedCaseVariant() throws Exception { + String id = java.util.UUID.randomUUID().toString(); var uuid = java.util.UUID.fromString(id); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var sql = real.createStatement()) { sql.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); sql.executeUpdate("INSERT INTO Users VALUES ('" + id + "', 1)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(real); table.setSqLite(sqlite); doNothing().when(table).checkColumn(any(Column.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + var add = com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD; + org.junit.jupiter.api.Assertions.assertTrue(manager.tryMutateLegacyIntegers(uuid, java.util.Map.of("activations", 1), add, null).applied()); + data.setValues(new java.util.HashMap<>(java.util.Map.of("Activations", new DataValueInt(10)))); + org.junit.jupiter.api.Assertions.assertTrue(manager.populateCompleteLegacyCache(uuid)); + org.junit.jupiter.api.Assertions.assertTrue(manager.tryMutateLegacyIntegers(uuid, java.util.Map.of("activations", 1), add, null).applied()); + cache.processChanges(); + try (var sql = real.createStatement(); var row = sql.executeQuery("SELECT Activations FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(11, row.getInt(1)); } + } finally { manager.getTimer().shutdownNow(); } + } + } + @Test void olderInsertCannotOverwriteNewerSqlCommitBeforeItsCallerReturns() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var sql = real.createStatement()) { sql.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + var olderReady = new java.util.concurrent.CountDownLatch(1); var allowOlder = new java.util.concurrent.CountDownLatch(1); + var newerCommitted = new java.util.concurrent.CountDownLatch(1); var allowNewerReturn = new java.util.concurrent.CountDownLatch(1); + var wrapped = mock(Connection.class); when(wrapped.unwrap(org.sqlite.SQLiteConnection.class)).thenReturn((org.sqlite.SQLiteConnection) real); + var count = new java.util.concurrent.atomic.AtomicInteger(); + when(wrapped.prepareStatement(anyString())).thenAnswer(call -> { String sql = call.getArgument(0); var prepared = real.prepareStatement(sql); if (!sql.startsWith("INSERT")) return prepared; var delayed = spy(prepared); + org.mockito.Mockito.doAnswer(invocation -> { int ordinal = count.getAndIncrement(); if (ordinal == 0) { olderReady.countDown(); if (!allowOlder.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return invocation.callRealMethod(); } + Object result = invocation.callRealMethod(); newerCommitted.countDown(); if (!allowNewerReturn.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); return result; }).when(delayed).executeUpdate(); return delayed; }); + when(sqlite.getSQLConnection()).thenReturn(wrapped); table.setSqLite(sqlite); doNothing().when(table).checkColumn(any(Column.class)); doReturn(false).when(table).containsKey(anyString()); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var older = pool.submit(() -> data.setInt("Activations", 2, false)); org.junit.jupiter.api.Assertions.assertTrue(olderReady.await(5, java.util.concurrent.TimeUnit.SECONDS)); + var newer = pool.submit(() -> data.setInt("Activations", 10, false)); org.junit.jupiter.api.Assertions.assertTrue(newerCommitted.await(5, java.util.concurrent.TimeUnit.SECONDS)); + allowOlder.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); allowNewerReturn.countDown(); newer.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var sql = real.createStatement(); var row = sql.executeQuery("SELECT Activations FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } finally { allowOlder.countDown(); allowNewerReturn.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void failedDirectSqliteScalarCanRetryAndRetire() throws Exception { + String id = java.util.UUID.randomUUID().toString(); var uuid = java.util.UUID.fromString(id); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var sql = real.createStatement()) { + sql.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); + sql.executeUpdate("INSERT INTO Users VALUES ('" + id + "', 1)"); + sql.executeUpdate("CREATE TRIGGER reject_write BEFORE UPDATE ON Users BEGIN SELECT RAISE(FAIL, 'reject'); END"); + } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(real); table.setSqLite(sqlite); doNothing().when(table).checkColumn(any(Column.class)); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + try { + data.setInt("Activations", 10, false); + org.junit.jupiter.api.Assertions.assertTrue(cache.hasChangesToProcess()); + try (var sql = real.createStatement()) { sql.executeUpdate("DROP TRIGGER reject_write"); } + cache.processChanges(); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + try (var sql = real.createStatement(); var row = sql.executeQuery("SELECT Activations FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } finally { manager.getTimer().shutdownNow(); } + } + } + @Test void sqlitePrimaryLookalikeDoesNotReplaceNewerColumn() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var sql = real.createStatement()) { sql.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, \"uuİd\" INTEGER, Activations INTEGER)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var wrapped = mock(Connection.class); when(wrapped.unwrap(org.sqlite.SQLiteConnection.class)).thenReturn((org.sqlite.SQLiteConnection) real); + var count = new java.util.concurrent.atomic.AtomicInteger(); + when(wrapped.prepareStatement(anyString())).thenAnswer(call -> { String sql = call.getArgument(0); var prepared = real.prepareStatement(sql); if (!sql.startsWith("INSERT")) return prepared; var delayed = spy(prepared); + org.mockito.Mockito.doAnswer(invocation -> { if (count.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return invocation.callRealMethod(); }).when(delayed).executeUpdate(); return delayed; }); + when(sqlite.getSQLConnection()).thenReturn(wrapped); table.setSqLite(sqlite); doNothing().when(table).checkColumn(any(Column.class)); doReturn(false).when(table).containsKey(anyString()); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var older = pool.submit(() -> data.setInt("uuİd", 2, false)); org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + pool.submit(() -> data.setInt("Activations", 10, false)).get(1, java.util.concurrent.TimeUnit.SECONDS); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var sql = real.createStatement(); var row = sql.executeQuery("SELECT \"uuİd\", Activations FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(2, row.getInt(1)); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(2)); } + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void failedLegacySqliteBatchRetainsItsQueuedCredit() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + var primary = new Column("uuid", new DataValueString(id)); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); table.setSqLite(sqlite); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(java.util.UUID.fromString(id), false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, java.util.UUID.fromString(id)); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(java.util.UUID.fromString(id), cache); + doNothing().when(table).checkColumn(any(Column.class)); doReturn(true).when(table).containsKey(anyString()); + when(connection.prepareStatement(anyString())).thenReturn(statement); + when(statement.executeUpdate()).thenThrow(new java.sql.SQLException("synthetic write failure")); + try { + org.junit.jupiter.api.Assertions.assertTrue(manager.tryMutateLegacyIntegers(java.util.UUID.fromString(id), java.util.Map.of("Activations", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, cache::processChanges); + org.junit.jupiter.api.Assertions.assertTrue(cache.hasChangesToProcess()); + org.junit.jupiter.api.Assertions.assertEquals(2, cache.getIfPresent("Activations").getInt()); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> table.update(primary, + new java.util.ArrayList<>(java.util.List.of(new Column("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(4)))))); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void olderLegacySqliteInsertCannotReplaceNewerDirectInsert() throws Exception { legacyInsertRace(false); } + @Test void sqliteInsertConflictAfterAdmissionDoesNotReplaceNewerDirectInsert() throws Exception { legacyInsertRace(true); } + private void legacyInsertRace(boolean pauseBeforeSql) throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); } + var primary = new Column("uuid", new DataValueString(id)); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var realSqlite = mock(SQLite.class); + if (pauseBeforeSql) { + var wrapped = mock(java.sql.Connection.class); + when(wrapped.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); var prepared = real.prepareStatement(sql); + if (!sql.startsWith("INSERT OR IGNORE")) return prepared; + var delayed = spy(prepared); + org.mockito.Mockito.doAnswer(invocation -> { entered.countDown(); + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); + return invocation.callRealMethod(); }).when(delayed).executeUpdate(); + return delayed; + }); + when(realSqlite.getSQLConnection()).thenReturn(wrapped); + } else when(realSqlite.getSQLConnection()).thenReturn(real); + table.setSqLite(realSqlite); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(java.util.UUID.fromString(id), false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, java.util.UUID.fromString(id)); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(java.util.UUID.fromString(id), cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + doNothing().when(table).checkColumn(any(Column.class)); + var existsCalls = new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.doAnswer(call -> existsCalls.getAndIncrement() >= 2).when(table).containsKey(anyString()); + var insertCalls = new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.doAnswer(call -> { if (!pauseBeforeSql && insertCalls.getAndIncrement() == 0) { entered.countDown(); + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + return call.callRealMethod(); }).when(table).insert(any()); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + org.junit.jupiter.api.Assertions.assertTrue(manager.tryMutateLegacyIntegers(java.util.UUID.fromString(id), java.util.Map.of("Activations", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + var older = worker.submit(cache::processChanges); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + data.setInt("Activations", 10, false); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT Activations FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); + } + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void sqliteInsertRetainsLegacyEmptyStringForNullColumn() throws Exception { + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Label TEXT)"); } + var primary = new Column("uuid", new DataValueString("id")); var plugin = mock(AdvancedCorePlugin.class); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(real); table.setSqLite(sqlite); doNothing().when(table).checkColumn(any(Column.class)); + table.insert(new java.util.ArrayList<>(java.util.List.of(primary, new Column("Label", com.bencodez.simpleapi.sql.DataType.STRING)))); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT Label FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals("", row.getString(1)); } + } + } + @Test void olderDirectSqliteInsertCannotReplaceNewerCompletedDirectInsert() throws Exception { directInsertRace(false, false, false, false); } + @Test void directInsertConflictAfterGuardCannotReplaceNewerCompletedInsert() throws Exception { directInsertRace(true, false, false, false); } + @Test void failedNewerDirectSqliteInsertDoesNotSuppressOlderSuccessfulWrite() throws Exception { directInsertRace(false, true, false, false); } + @Test void directSqliteRetirementWaitsWithoutBlockingAndSurvivesObserverCancellation() throws Exception { directInsertRace(true, false, true, false); } + @Test void directSqliteColumnCaseVariantsShareTheSameOrdering() throws Exception { directInsertRace(true, false, false, true); } + private void directInsertRace(boolean pauseBeforeSql, boolean failNewer, boolean retire, boolean caseVariant) throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER" + (failNewer ? " CHECK (Activations <> 10)" : "") + ")"); } + var primary = new Column("uuid", new DataValueString(id)); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var realSqlite = mock(SQLite.class); + if (pauseBeforeSql) { + var wrapped = mock(Connection.class); when(wrapped.unwrap(org.sqlite.SQLiteConnection.class)).thenReturn((org.sqlite.SQLiteConnection) real); var executions = new java.util.concurrent.atomic.AtomicInteger(); + when(wrapped.prepareStatement(anyString())).thenAnswer(call -> { + String sql = call.getArgument(0); var prepared = real.prepareStatement(sql); + if (!sql.startsWith("INSERT")) return prepared; + var delayed = spy(prepared); + org.mockito.Mockito.doAnswer(invocation -> { if (executions.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return invocation.callRealMethod(); }).when(delayed).executeUpdate(); + return delayed; + }); + when(realSqlite.getSQLConnection()).thenReturn(wrapped); + } else when(realSqlite.getSQLConnection()).thenReturn(real); + table.setSqLite(realSqlite); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + doNothing().when(table).checkColumn(any(Column.class)); doReturn(false).when(table).containsKey(anyString()); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.doAnswer(call -> { if (!pauseBeforeSql && calls.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return call.callRealMethod(); }).when(table).insert(any()); + var data = new com.bencodez.advancedcore.api.user.UserData(user); var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var older = pool.submit(() -> data.setInt("Activations", 2, false)); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + java.util.concurrent.CompletableFuture retirement = null; + if (retire) { + var cancelled = manager.clearCacheAsyncCompletion().toCompletableFuture(); retirement = manager.clearCacheAsyncCompletion().toCompletableFuture(); + org.junit.jupiter.api.Assertions.assertTrue(cancelled.cancel(false)); org.junit.jupiter.api.Assertions.assertFalse(retirement.isDone()); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> data.setInt("Activations", 10, false)); + } else pool.submit(() -> data.setInt(caseVariant ? "activations" : "Activations", 10, false)).get(1, java.util.concurrent.TimeUnit.SECONDS); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + if (retirement != null) retirement.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT Activations FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(failNewer || retire ? 2 : 10, row.getInt(1), "a failed newer insert must not suppress the older write"); + } + if (failNewer) { + org.junit.jupiter.api.Assertions.assertThrows(java.util.concurrent.CompletionException.class, () -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + data.setInt("Activations", 3, false); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + } + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void olderDirectSqliteExistingRowCannotOverwriteNewerCompletedWrite() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); create.executeUpdate("INSERT INTO Users VALUES ('" + id + "', 1)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(real); table.setSqLite(sqlite); when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + doNothing().when(table).checkColumn(any(Column.class)); var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); var calls = new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.doAnswer(call -> { if (calls.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return true; }).when(table).containsKey(anyString()); + var data = new com.bencodez.advancedcore.api.user.UserData(user); var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var older = pool.submit(() -> data.setInt("Activations", 2, false)); org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + pool.submit(() -> data.setInt("Activations", 10, false)).get(1, java.util.concurrent.TimeUnit.SECONDS); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT Activations FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void olderDirectSqliteBulkInsertPreservesNewerKeyAndUnrelatedKey() throws Exception { directBulkInsertRace(false, false); } + @Test void olderDirectSqliteBulkConflictAfterGuardPreservesBothKeys() throws Exception { directBulkInsertRace(true, false); } + @Test void sqliteNonAsciiColumnNamesRemainDistinct() throws Exception { directBulkInsertRace(true, true); } + private void directBulkInsertRace(boolean pauseBeforeSql, boolean nonAscii) throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate(nonAscii ? "CREATE TABLE Users (uuid TEXT PRIMARY KEY, Ä INTEGER, ä INTEGER, Other INTEGER)" : "CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER, Other INTEGER)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + if (pauseBeforeSql) { + var wrapped = mock(Connection.class); when(wrapped.unwrap(org.sqlite.SQLiteConnection.class)).thenReturn((org.sqlite.SQLiteConnection) real); var executions = new java.util.concurrent.atomic.AtomicInteger(); + when(wrapped.prepareStatement(anyString())).thenAnswer(call -> { String sql = call.getArgument(0); var prepared = real.prepareStatement(sql); if (!sql.startsWith("INSERT")) return prepared; var delayed = spy(prepared); + org.mockito.Mockito.doAnswer(invocation -> { if (executions.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return invocation.callRealMethod(); }).when(delayed).executeUpdate(); return delayed; }); + when(sqlite.getSQLConnection()).thenReturn(wrapped); + } else when(sqlite.getSQLConnection()).thenReturn(real); + table.setSqLite(sqlite); when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); + doNothing().when(table).checkColumn(any(Column.class)); doReturn(false).when(table).containsKey(anyString()); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.doAnswer(call -> { if (!pauseBeforeSql && calls.getAndIncrement() == 0) { entered.countDown(); if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } return call.callRealMethod(); }).when(table).insert(any()); + var data = new com.bencodez.advancedcore.api.user.UserData(user); var pool = java.util.concurrent.Executors.newFixedThreadPool(2); + try { + var values = new java.util.HashMap(); values.put(nonAscii ? "Ä" : "Activations", new DataValueInt(2)); values.put("Other", new DataValueInt(3)); + var older = pool.submit(() -> data.setValues(com.bencodez.advancedcore.api.user.UserStorage.SQLITE, values)); org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + pool.submit(() -> data.setInt(nonAscii ? "ä" : "Activations", 10, false)).get(1, java.util.concurrent.TimeUnit.SECONDS); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var query = real.createStatement(); var row = query.executeQuery(nonAscii ? "SELECT Ä, ä, Other FROM Users" : "SELECT Activations, Other FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(nonAscii ? 2 : 10, row.getInt(1)); if (nonAscii) { org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(2)); org.junit.jupiter.api.Assertions.assertEquals(3, row.getInt(3)); } else org.junit.jupiter.api.Assertions.assertEquals(3, row.getInt(2)); } + } finally { release.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void directSqliteGuardRegistersOnReplacementConnection() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var first = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:"); var second = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + for (var connection : java.util.List.of(first, second)) try (var create = connection.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(first); table.setSqLite(sqlite); when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); doNothing().when(table).checkColumn(any(Column.class)); doReturn(false).when(table).containsKey(anyString()); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + try { + data.setInt("Activations", 2, false); when(sqlite.getSQLConnection()).thenReturn(second); data.setInt("Activations", 10, false); + try (var query = second.createStatement(); var row = query.executeQuery("SELECT Activations FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); } + } finally { manager.getTimer().shutdownNow(); } + } + } + @Test void directSqliteNotificationCanReenterWithAnotherWrite() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER, Other INTEGER)"); } + var primary = new Column("uuid", new DataValueString(id)); var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(real); table.setSqLite(sqlite); when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(id); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); doNothing().when(table).checkColumn(any(Column.class)); + var data = new com.bencodez.advancedcore.api.user.UserData(user); + org.mockito.Mockito.doAnswer(call -> { data.setInt("Other", 5, false); return null; }).when(users).onChange(org.mockito.ArgumentMatchers.eq(user), org.mockito.ArgumentMatchers.eq("Activations")); + try { + data.setInt("Activations", 10, false); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT Activations, Other FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); org.junit.jupiter.api.Assertions.assertEquals(5, row.getInt(2)); } + } finally { manager.getTimer().shutdownNow(); } + } + } + @Test void concurrentFirstDirectWritesCanInitializeSqliteGuard() throws Exception { + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); } + var primary = new Column("uuid", new DataValueString("placeholder")); var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); var sqlite = mock(SQLite.class); + when(sqlite.getSQLConnection()).thenReturn(real); table.setSqLite(sqlite); when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); when(plugin.getSQLiteUserTable()).thenReturn(table); doNothing().when(table).checkColumn(any(Column.class)); doReturn(false).when(table).containsKey(anyString()); + int count = 12; var ready = new java.util.concurrent.CountDownLatch(count); var start = new java.util.concurrent.CountDownLatch(1); var pool = java.util.concurrent.Executors.newFixedThreadPool(count); var tasks = new java.util.ArrayList>(); + try { + for (int i=0; i { ready.countDown(); try { if (!start.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IllegalStateException(e); } data.setInt("Activations", 1, false); })); } + org.junit.jupiter.api.Assertions.assertTrue(ready.await(5, java.util.concurrent.TimeUnit.SECONDS)); start.countDown(); + for (var task : tasks) task.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT COUNT(*) FROM Users")) { org.junit.jupiter.api.Assertions.assertTrue(row.next()); org.junit.jupiter.api.Assertions.assertEquals(count, row.getInt(1)); } + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> manager.clearCacheAsyncCompletion().toCompletableFuture().join()); + } finally { start.countDown(); pool.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test void olderLegacySqliteUpdateCannotOverwriteNewerDirectUpdate() throws Exception { + String id = java.util.UUID.randomUUID().toString(); + try (var real = java.sql.DriverManager.getConnection("jdbc:sqlite::memory:")) { + try (var create = real.createStatement()) { + create.executeUpdate("CREATE TABLE Users (uuid TEXT PRIMARY KEY, Activations INTEGER)"); + create.executeUpdate("INSERT INTO Users (uuid, Activations) VALUES ('" + id + "', 1)"); + } + var primary = new Column("uuid", new DataValueString(id)); + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var manager = new com.bencodez.advancedcore.api.user.usercache.UserDataManager(plugin); + var table = spy(new UserTable(plugin, "Users", Collections.singletonList(primary), primary)); + var realSqlite = mock(SQLite.class); when(realSqlite.getSQLConnection()).thenReturn(real); table.setSqLite(realSqlite); + when(plugin.getUserManager()).thenReturn(users); when(users.getDataManager()).thenReturn(manager); + when(users.getUser(java.util.UUID.fromString(id), false)).thenReturn(user); when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(id); when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + var data = new com.bencodez.advancedcore.api.user.UserData(user); when(user.getUserData()).thenReturn(data); + var cache = new com.bencodez.advancedcore.api.user.usercache.UserDataCache(manager, java.util.UUID.fromString(id)); + cache.updateCompleteCachePreservingPending(new java.util.HashMap<>(java.util.Map.of("Activations", new com.bencodez.simpleapi.sql.data.DataValueInt(1)))); + manager.getUserDataCache().put(java.util.UUID.fromString(id), cache); + when(user.isCached()).thenReturn(true); when(user.getCache()).thenReturn(cache); + doNothing().when(table).checkColumn(any(Column.class)); + var entered = new java.util.concurrent.CountDownLatch(1); var release = new java.util.concurrent.CountDownLatch(1); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + org.mockito.Mockito.doAnswer(call -> { if (calls.getAndIncrement() == 0) { entered.countDown(); + if (!release.await(5, java.util.concurrent.TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + return true; }).when(table).containsKey(anyString()); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + org.junit.jupiter.api.Assertions.assertTrue(manager.tryMutateLegacyIntegers(java.util.UUID.fromString(id), java.util.Map.of("Activations", 1), + com.bencodez.advancedcore.api.user.usercache.UserDataCache.IntegerMutation.ADD, null).applied()); + var older = worker.submit(cache::processChanges); + org.junit.jupiter.api.Assertions.assertTrue(entered.await(5, java.util.concurrent.TimeUnit.SECONDS)); + data.setInt("Activations", 10, false); + release.countDown(); older.get(5, java.util.concurrent.TimeUnit.SECONDS); + try (var query = real.createStatement(); var row = query.executeQuery("SELECT Activations FROM Users")) { + org.junit.jupiter.api.Assertions.assertTrue(row.next()); + org.junit.jupiter.api.Assertions.assertEquals(10, row.getInt(1)); + } + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + } + @Test + void strictReadRetainsFailureThroughDelegatingOverride() throws Exception { + Column primaryKey = new Column("uuid", new DataValueString(UUID)); + UserTable table = new UserTable(mock(AdvancedCorePlugin.class), "Users", Collections.singletonList(primaryKey), primaryKey) { + @Override public java.util.ArrayList getExact(Column column) { return super.getExact(column); } + }; + table.setSqLite(sqlite); + when(connection.prepareStatement("SELECT * FROM Users WHERE `uuid`=?")).thenThrow(new java.sql.SQLException("synthetic read failure")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> table.getExactStrict(primaryKey)); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(() -> table.getExact(primaryKey)); + } private static final String UUID = "00000000-0000-0000-0000-000000000001"; @@ -41,6 +2434,40 @@ void setUp() throws Exception { when(sqlite.getSQLConnection()).thenReturn(connection); } + @Test + void strictExactReadPropagatesDatabaseFailure() throws Exception { + Column primaryKey = new Column("uuid", new DataValueString(UUID)); + UserTable table = new UserTable(mock(AdvancedCorePlugin.class), "Users", Collections.singletonList(primaryKey), primaryKey); + table.setSqLite(sqlite); + when(connection.prepareStatement("SELECT * FROM Users WHERE `uuid`=?")) + .thenThrow(new java.sql.SQLException("synthetic read failure")); + var failure = org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> table.getExactStrict(primaryKey)); + org.junit.jupiter.api.Assertions.assertInstanceOf(java.sql.SQLException.class, failure.getCause()); + } + + @Test + void strictExactReadClosesStatementWhenExecutionFails() throws Exception { + Column primaryKey = new Column("uuid", new DataValueString(UUID)); + UserTable table = new UserTable(mock(AdvancedCorePlugin.class), "Users", Collections.singletonList(primaryKey), primaryKey); + table.setSqLite(sqlite); + when(connection.prepareStatement("SELECT * FROM Users WHERE `uuid`=?")).thenReturn(statement); + when(statement.executeQuery()).thenThrow(new java.sql.SQLException("synthetic read failure")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> table.getExactStrict(primaryKey)); + verify(statement).close(); + } + + @Test + void strictExactReadClosesResultAndStatementWhenIterationFails() throws Exception { + Column primaryKey = new Column("uuid", new DataValueString(UUID)); + UserTable table = new UserTable(mock(AdvancedCorePlugin.class), "Users", Collections.singletonList(primaryKey), primaryKey); + table.setSqLite(sqlite); ResultSet row = mock(ResultSet.class); + when(connection.prepareStatement("SELECT * FROM Users WHERE `uuid`=?")).thenReturn(statement); + when(statement.executeQuery()).thenReturn(row); + when(row.next()).thenThrow(new java.sql.SQLException("synthetic iteration failure")); + org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, () -> table.getExactStrict(primaryKey)); + verify(row).close(); verify(statement).close(); + } + @Test void updateBindsValuesInsteadOfEmbeddingThemInSql() throws Exception { Column primaryKey = new Column("uuid", new DataValueString(UUID)); From c349c52bccf50b8ed724eb834aed8bfc54faf4b4 Mon Sep 17 00:00:00 2001 From: Ben Date: Wed, 4 Nov 2026 01:45:22 -0600 Subject: [PATCH 2/2] Complete cache retirement, reward replay and identity handling --- .../advancedcore/api/misc/MiscUtils.java | 132 ++++- .../advancedcore/api/misc/PlayerManager.java | 6 +- .../advancedcore/api/rewards/Reward.java | 62 ++- .../api/rewards/RewardOptions.java | 46 ++ .../api/rewards/builtin/RewardCommands.java | 8 +- .../api/user/AdvancedCoreUser.java | 40 +- .../advancedcore/api/user/UserData.java | 9 + .../api/user/usercache/UserDataCache.java | 115 ++-- .../api/user/usercache/UserDataManager.java | 296 ++++++++--- .../user/runtime/BukkitUserCacheOwner.java | 9 +- .../user/storage/BukkitSqlUserBackend.java | 7 + .../user/storage/sql/MysqlUserBackend.java | 7 + .../core/user/storage/sql/SqlUserBackend.java | 3 + .../misc/ConsoleMissingIdentityTest.java | 494 ++++++++++++++++++ .../rewards/RewardAsyncInjectionTest.java | 225 ++++++++ .../storage/SqliteUserTransactionTest.java | 12 +- .../tests/user/AdvancedCoreUserTest.java | 57 ++ .../tests/user/AtomicIntegerMutationTest.java | 204 +++++++- .../SharedCacheBindingRegressionTest.java | 326 +++++++++++- .../user/SharedCachePopulationRaceTest.java | 46 ++ .../SharedUserLifecycleRegressionTest.java | 4 +- .../advancedcore/tests/user/UserDataTest.java | 15 + 22 files changed, 1951 insertions(+), 172 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java index 511c9abe8c..8cc930a86b 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/MiscUtils.java @@ -46,6 +46,8 @@ import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.item.ItemBuilder; +import com.bencodez.advancedcore.api.javascript.JavascriptPlaceholderValue; +import com.bencodez.advancedcore.api.javascript.JavascriptTextTemplate; import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.bukkit.platform.BukkitPlatformServices; @@ -244,16 +246,7 @@ public void run() { public void executeConsoleCommands(final String playerName, final ArrayList cmds, final HashMap placeholders, boolean stagger) { if (cmds != null && !cmds.isEmpty()) { - placeholders.put("player", playerName); - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); - ArrayList commands = cmds; - if (p != null) { - commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); - } - commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); - if (p != null) { - commands = PlaceholderUtils.replacePlaceHolders(p, commands); - } + ArrayList commands = expandConsoleCommands(playerName, cmds, placeholders, true); int tick = 0; for (final String cmd : commands) { plugin.debug("Executing console command: " + cmd); @@ -264,6 +257,10 @@ public void executeConsoleCommands(final String playerName, final ArrayList placeholders) { + if ((playerName == null || playerName.isBlank()) && !Reward.hasReplayCommandSnapshot(placeholders, "console")) { + expandConsoleCommands(playerName, command == null || command.isEmpty() + ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)), placeholders, false); + } executeConsoleCommandsAsync(playerName, command, placeholders); } @@ -284,15 +281,8 @@ public CompletionStage executeConsoleCommandsAsync(String playerName, Stri try { ArrayList templates = command == null || command.isEmpty() ? new ArrayList<>() : new ArrayList<>(java.util.List.of(command)); - String expanded = command; - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); - if (expanded != null && !expanded.isEmpty()) { - if (p != null) expanded = PlaceholderUtils.replaceJavascriptOnly(p, expanded); - expanded = PlaceholderUtils.replacePlaceHolder(expanded, placeholders); - if (p != null) expanded = PlaceholderUtils.replacePlaceHolders(p, expanded); - } - ArrayList commands = command == null || command.isEmpty() - ? new ArrayList<>() : new ArrayList<>(java.util.List.of(expanded == null ? "" : expanded)); + ArrayList commands = Reward.hasReplayCommandSnapshot(placeholders, "console") + ? new ArrayList<>() : expandConsoleCommands(playerName, templates, placeholders, false); return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, (cmd, ignoredIndex) -> { plugin.debug("Executing console command: " + cmd); @@ -319,16 +309,20 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, final HashMap placeholders, final boolean stagger, AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard) { + return executeConsoleCommandsAsync(playerName, cmds, placeholders, stagger, eligibilityGuard, + Reward.currentReplayState(), Reward.currentReplayKey()); + } + + /** Carries the replay context across asynchronous availability checks. */ + public CompletionStage executeConsoleCommandsAsync(final String playerName, final ArrayList cmds, + final HashMap placeholders, final boolean stagger, + AdvancedCoreUser.PlayerEligibilityGuard eligibilityGuard, Reward.ReplayState replayState, String replayKey) { try { - placeholders.put("player", playerName); - OfflinePlayer p = Bukkit.getOfflinePlayer(playerName); ArrayList templates = cmds == null ? new ArrayList<>() : new ArrayList<>(cmds); - ArrayList commands = new ArrayList<>(templates); - if (p != null) commands = PlaceholderUtils.replaceJavascriptOnly(p, commands); - commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); - if (p != null) commands = PlaceholderUtils.replacePlaceHolders(p, commands); + ArrayList commands = Reward.hasReplayCommandSnapshot(placeholders, "console", replayState, replayKey) + ? new ArrayList<>() : expandConsoleCommands(playerName, templates, placeholders, true); - return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, + return Reward.replayCommandSequence(plugin, placeholders, "console", templates, commands, replayState, replayKey, (command, index) -> { plugin.debug("Executing console command: " + command); return runConsoleCommandAsync(stripLeadingSlash(command), index > 0 ? 1 : 0, @@ -339,6 +333,92 @@ public CompletionStage executeConsoleCommandsAsync(final String playerName } } + @SuppressWarnings("deprecation") + private ArrayList expandConsoleCommands(String playerName, List templates, + HashMap placeholders, boolean addPlayer) { + ArrayList commands = new ArrayList<>(templates); + if (commands.isEmpty()) return commands; + boolean hasName = playerName != null && !playerName.isBlank(); + if (hasName) { + if (addPlayer) placeholders.put("player", playerName); + OfflinePlayer player = Bukkit.getOfflinePlayer(playerName); + if (player != null) commands = PlaceholderUtils.replaceJavascriptOnly(player, commands); + commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); + if (player != null) commands = PlaceholderUtils.replacePlaceHolders(player, commands); + return commands; + } + for (String command : commands) { + if (command != null && (containsPlayerNamePlaceholder(command) + || (plugin.getOptions().isJavascriptEngineEnabled() + && containsJavascriptSegment(command)))) { + throw new IllegalStateException("Console command requires an unavailable player identity"); + } + } + // Trace identity aliases through map substitutions before an empty value can + // erase them. Other map entries may themselves introduce an alias. + String marker = "__advancedcore_missing_name_" + java.util.UUID.randomUUID() + "__"; + HashMap guarded = placeholders == null ? new LinkedHashMap<>() : new LinkedHashMap<>(placeholders); + guarded.replaceAll((key, value) -> key != null && (key.equalsIgnoreCase("player") || key.equalsIgnoreCase("@p")) + ? marker : value); + guarded.put("player", marker); + guarded.put("@p", marker); + String encodedMarker = JavascriptPlaceholderValue.encode(marker); + for (String command : PlaceholderUtils.replacePlaceHolder(commands, guarded)) { + if (command != null && (command.contains(marker) || command.contains(encodedMarker) + || containsPlayerNamePlaceholder(command))) { + throw new IllegalStateException("Console command requires an unavailable player identity"); + } + } + commands = PlaceholderUtils.replacePlaceHolder(commands, placeholders); + for (String command : commands) { + if (command != null && (containsPlayerNamePlaceholder(command) + || (plugin.isPlaceHolderAPIEnabled() && containsProcessedExternalPlaceholder(command)))) { + throw new IllegalStateException("Console command placeholders require an unavailable player identity"); + } + } + return commands; + } + + private static boolean containsPlayerNamePlaceholder(String command) { + return Pattern.compile("(?i)%(?:player|@p)%|\\{(?:player|@p)\\}").matcher(command).find(); + } + + private static boolean containsJavascriptSegment(String command) { + AtomicBoolean found = new AtomicBoolean(); + JavascriptTextTemplate.parse(command).transform(Function.identity(), script -> { + found.set(true); + return script; + }); + return found.get(); + } + + /** PlaceholderAPI processes literal segments, not authored JavaScript bodies. */ + private static boolean containsProcessedExternalPlaceholder(String command) { + AtomicBoolean found = new AtomicBoolean(); + JavascriptTextTemplate.parse(command).transform(text -> { + if (containsExternalPlaceholder(text)) found.set(true); + return text; + }, Function.identity()); + return found.get(); + } + + /** Matches the percent-delimited expansion boundaries used by PlaceholderAPI. */ + private static boolean containsExternalPlaceholder(String text) { + int start = text.indexOf('%'); + while (start >= 0) { + int end = text.indexOf('%', start + 1); + if (end < 0) return false; + boolean abandoned = false; + for (int index = start + 1; index < end; index++) { + char c = text.charAt(index); + if (c == '_') return true; + if (c == ' ') { abandoned = true; break; } + } + start = text.indexOf('%', abandoned ? start + 1 : end + 1); + } + return false; + } + public Object getBlockMeta(Block block, String str) { for (MetadataValue meta : block.getMetadata(str)) { if (meta.getOwningPlugin().equals(plugin)) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java index 0132ec6554..aa2e67bbde 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/misc/PlayerManager.java @@ -215,7 +215,7 @@ public boolean hasPermission(Player player, String perm) { * @return true, if successful */ public boolean hasPermission(String playerName, String perm) { - if (playerName == null) { + if (playerName == null || playerName.isBlank()) { return false; } Player player = Bukkit.getPlayer(playerName); @@ -233,7 +233,7 @@ public boolean hasPermission(String playerName, String perm) { * @return true, if successful */ public boolean hasServerPermission(String playerName, String perm) { - if (playerName == null) { + if (playerName == null || playerName.isBlank()) { return false; } Player player = Bukkit.getPlayer(playerName); @@ -258,7 +258,7 @@ public boolean hasServerPermission(UUID playerUUID, String playername, String pe Player player = Bukkit.getPlayer(playerUUID); if (player != null) { return player.hasPermission(perm); - } else { + } else if (playername != null && !playername.isBlank()) { player = Bukkit.getPlayer(playername); if (player != null && playerUUID.equals(player.getUniqueId())) { return player.hasPermission(perm); 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 22cac38afb..ecc4347dd4 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 @@ -247,7 +247,7 @@ public String getRewardName() { public void giveInjectedRewards(AdvancedCoreUser user, HashMap placeholders) { if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { giveRewardUserAsync(user, placeholders, new RewardOptions().setCheckTimed(false)).exceptionally(failure -> { - logRewardUserFailure(failure); + logRewardUserFailure(failure, null); return null; }); return; @@ -543,7 +543,8 @@ public static ReplayState replayStateFor(RewardOptions options) { // fresh top-level execution state local to this dispatch so completed // stages cannot leak into the next send. replayState = new ReplayState(options.getAsyncReplayProgress(), - options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint()); + options.getAsyncReplayRegistryFingerprints(), options.isLegacyAsyncReplayCheckpoint(), + options.isRestoredAsyncReplayEntry(), options.isFreshAsyncReplayEntry()); } else { // Deferred/nested options must retain their explicitly inherited parent // state after the current thread-local scope ends. @@ -869,8 +870,12 @@ private static String replaySequenceKey(String prefix, String activeKey, String /** Returns whether the active replay already froze this logical command lane. */ public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane) { - ReplayState replayState = currentReplayState(); - String activeKey = currentReplayKey(); + return hasReplayCommandSnapshot(placeholders, lane, currentReplayState(), currentReplayKey()); + } + + /** Checks a captured replay context after an asynchronous continuation. */ + public static boolean hasReplayCommandSnapshot(HashMap placeholders, String lane, + ReplayState replayState, String activeKey) { if (replayState == null || activeKey == null) return false; String storageKey = replaySequenceKey(REPLAY_COMMAND_PREFIX, activeKey, lane == null ? "commands" : lane); @@ -1037,6 +1042,11 @@ private static boolean isReplayMetadataKey(String key) { || key.startsWith(REPLAY_LEGACY_ACTION_PREFIX) || key.startsWith(REPLAY_CHANCE_PREFIX)); } + /** Remove reserved replay metadata from untrusted fresh queue input. */ + public static void clearReplayMetadata(HashMap placeholders) { + if (placeholders != null) placeholders.keySet().removeIf(Reward::isReplayMetadataKey); + } + /** Stable metadata key for the individual legacy actions produced by one injector. */ public static String legacyActionReplayKey(String injectionKey) { String encoded = Base64.getUrlEncoder().withoutPadding().encodeToString( @@ -1128,6 +1138,7 @@ public static final class ReplayState { private final HashMap replayMetadata = new HashMap<>(); private final boolean legacyCheckpoint; private final boolean restoredCheckpoint; + private final boolean freshEntry; private boolean livePlayerStateSet; private boolean livePlayerOnline; private boolean livePlayerVanished; @@ -1136,10 +1147,15 @@ public static final class ReplayState { private ReplayState(Map initial) { this(initial, null, false); } private ReplayState(Map initial, Map initialFingerprints, boolean legacyCheckpoint) { + this(initial, initialFingerprints, legacyCheckpoint, false, false); + } + private ReplayState(Map initial, Map initialFingerprints, + boolean legacyCheckpoint, boolean restoredEntry, boolean freshEntry) { if (initial != null) completed.putAll(initial); if (initialFingerprints != null) registryFingerprints.putAll(initialFingerprints); this.legacyCheckpoint = legacyCheckpoint; - this.restoredCheckpoint = legacyCheckpoint || (initial != null && !initial.isEmpty()) + this.freshEntry = freshEntry; + this.restoredCheckpoint = restoredEntry || legacyCheckpoint || (initial != null && !initial.isEmpty()) || (initialFingerprints != null && !initialFingerprints.isEmpty()); } private synchronized int getCompleted(String rewardName, int fallback) { @@ -1159,7 +1175,8 @@ public synchronized void recordReplayMetadata(String key, String value) { } public synchronized void mergeReplayMetadataInto(HashMap target) { if (!restoredCheckpoint) target.keySet().removeIf(key -> - key != null && key.startsWith(REPLAY_SELECTION_PREFIX) && !replayMetadata.containsKey(key)); + (freshEntry ? isReplayMetadataKey(key) : key != null && key.startsWith(REPLAY_SELECTION_PREFIX)) + && !replayMetadata.containsKey(key)); Reward.mergeReplayMetadata(target, replayMetadata); } public synchronized String replayMetadata(String key) { return replayMetadata.get(key); } @@ -1212,7 +1229,7 @@ private synchronized String replayPlaceholderKey(String executionKey, boolean ow return checkpointConsumer != null && queueReplayKey != null ? queueReplayKey : executionKey; } private synchronized boolean acceptsPersistedMetadata() { - return restoredCheckpoint || checkpointConsumer != null; + return restoredCheckpoint || (!freshEntry && checkpointConsumer != null); } public CompletionStage persistCheckpointAsync(AdvancedCorePlugin plugin, HashMap placeholders) { @@ -1496,8 +1513,9 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { // player owner. Unconfigured legacy definitions retain their established path. if (RewardEligibility.isConfigured(plugin, getConfig().getConfigData()) && (rewardOptions == null || !rewardOptions.isIgnoreRequirements())) { - giveRewardAsync(user, rewardOptions).exceptionally(failure -> { - logRewardUserFailure(failure); + RewardOptions tracked = (rewardOptions == null ? new RewardOptions() : rewardOptions).captureLegacyFailureObserver(); + giveRewardAsync(user, tracked).exceptionally(failure -> { + logRewardUserFailure(failure, tracked.legacyFailureObserverCapture().get()); return null; }); return; @@ -1627,6 +1645,9 @@ public void giveReward(AdvancedCoreUser user, RewardOptions rewardOptions) { * @return completion stage for the complete reward injection chain */ public CompletionStage giveRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { + if (rewardOptions != null && !rewardOptions.claimLegacyFailureObserverCapture()) { + rewardOptions = rewardOptions.withoutLegacyFailureObserverCapture(); + } boolean primaryThread = false; try { primaryThread = Bukkit.isPrimaryThread(); @@ -1676,6 +1697,12 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R } rewardOptions = rewardOptions == null ? new RewardOptions() : rewardOptions.copyForDispatch(); + if (rewardOptions.getAsyncReplayState() == null && rewardOptions.getAsyncReplayCheckpointConsumer() == null + && !rewardOptions.isRestoredAsyncReplayEntry() && !rewardOptions.isLegacyAsyncReplayCheckpoint() + && rewardOptions.getAsyncReplayProgress().isEmpty() + && rewardOptions.getAsyncReplayRegistryFingerprints().isEmpty()) { + rewardOptions.setFreshAsyncReplayEntry(true); + } if (!rewardOptions.getPlaceholders().containsKey("ExecDate")) { rewardOptions.addPlaceholder("ExecDate", "" + System.currentTimeMillis()); } @@ -1692,6 +1719,7 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R PlayerRewardEvent event = new PlayerRewardEvent(this, user, rewardOptions); Bukkit.getPluginManager().callEvent(event); + rewardOptions.updateLegacyFailureObserverCapture(); if (event.isCancelled()) { plugin.debug("Reward " + name + " was cancelled for " + user.getPlayerName()); return CompletableFuture.completedFuture(null); @@ -1792,7 +1820,9 @@ private CompletionStage evaluateLiveRewardDecision(AdvancedC && RewardEligibility.isConfigured(plugin, getConfig().getConfigData())) { String key = chanceReplayKey(options); options.addPlaceholder(key, "1"); - if (options.getAsyncReplayState() != null) options.getAsyncReplayState().recordReplayMetadata(key, "1"); + ReplayState state = options.getAsyncReplayState(); + if (state == null) { state = replayStateFor(options); options.setAsyncReplayState(state); } + state.recordReplayMetadata(key, "1"); } return current; }); @@ -1928,8 +1958,9 @@ private RewardReplayFailure replayFailureWithSnapshot(RewardOptions options, Thr public void giveRewardUser(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { RewardOptions effectiveOptions = rewardOptions == null ? new RewardOptions() : rewardOptions; if (requiresAwaitedDelivery(effectiveOptions)) { + Consumer observer = effectiveOptions.getAsyncFailureObserver(); giveRewardUserAsync(user, phs, effectiveOptions).exceptionally(failure -> { - logRewardUserFailure(failure); + logRewardUserFailure(failure, observer); return null; }); return; @@ -2048,7 +2079,8 @@ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap Player player = user.getPlayer(); if (player == null) { - player = Bukkit.getPlayer(user.getPlayerName()); + String playerName = user.getPlayerName(); + if (playerName != null && !playerName.isBlank()) player = Bukkit.getPlayer(playerName); } if (player != null || isForceOffline()) { @@ -2081,7 +2113,11 @@ private HashMap prepareRewardUser(AdvancedCoreUser user, HashMap } } - private void logRewardUserFailure(Throwable failure) { + private void logRewardUserFailure(Throwable failure, Consumer observer) { + if (observer != null) { + try { observer.accept(failure); } + catch (Throwable ignored) { /* Diagnostics must not replace the original failure. */ } + } if (plugin.getLogger() != null) { plugin.getLogger().log(Level.WARNING, "Failed to give reward " + getRewardName(), failure); } else { 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 c174557e8b..e468bf6de0 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 @@ -4,6 +4,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.atomic.AtomicBoolean; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.simpleapi.array.ArrayUtils; @@ -75,6 +77,16 @@ public class RewardOptions { @Setter private boolean legacyAsyncReplayCheckpoint; + /** True only when loading an existing durable queue entry. */ + @Getter + @Setter + private boolean restoredAsyncReplayEntry; + + /** Explicit provenance for a newly created internal queue occurrence. */ + @Getter + @Setter + private boolean freshAsyncReplayEntry; + @Getter @Setter private Reward.ReplayState asyncReplayState; @@ -100,6 +112,35 @@ public class RewardOptions { @Setter private Consumer asyncReplayCheckpointConsumer; + /** Optional best-effort observer for a legacy fire-and-forget terminal failure. + * Invoked on the completion thread; it must not block or assume Bukkit ownership. */ + @Getter + @Setter + private Consumer asyncFailureObserver; + private LegacyFailureObserverCapture legacyFailureObserverCapture; + private static final class LegacyFailureObserverCapture { + private final AtomicReference> observer; + private final AtomicBoolean claimed = new AtomicBoolean(); + private LegacyFailureObserverCapture(Consumer observer) { this.observer = new AtomicReference<>(observer); } + } + RewardOptions captureLegacyFailureObserver() { + RewardOptions copy = copyForDispatch(); + copy.legacyFailureObserverCapture = new LegacyFailureObserverCapture(copy.asyncFailureObserver); + return copy; + } + AtomicReference> legacyFailureObserverCapture() { + return legacyFailureObserverCapture == null ? null : legacyFailureObserverCapture.observer; + } + boolean claimLegacyFailureObserverCapture() { + return legacyFailureObserverCapture == null || legacyFailureObserverCapture.claimed.compareAndSet(false, true); + } + RewardOptions withoutLegacyFailureObserverCapture() { + RewardOptions copy = copyForDispatch(); copy.legacyFailureObserverCapture = null; return copy; + } + void updateLegacyFailureObserverCapture() { + if (legacyFailureObserverCapture != null) legacyFailureObserverCapture.observer.set(asyncFailureObserver); + } + /** True while replaying an entry that must remain in the timed queue on deferral. */ @Getter @Setter @@ -272,7 +313,11 @@ RewardOptions copyForDispatch() { copy.setAsyncReplayProgress(new HashMap<>(asyncReplayProgress)); copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints)); copy.setLegacyAsyncReplayCheckpoint(legacyAsyncReplayCheckpoint); + copy.setRestoredAsyncReplayEntry(restoredAsyncReplayEntry); + copy.setFreshAsyncReplayEntry(freshAsyncReplayEntry); copy.setAsyncReplayCheckpointConsumer(asyncReplayCheckpointConsumer); + copy.setAsyncFailureObserver(asyncFailureObserver); + copy.legacyFailureObserverCapture = legacyFailureObserverCapture; copy.setAsyncReplayKey(asyncReplayKey); copy.setAsyncReplayOccurrenceId(asyncReplayOccurrenceId); copy.setTimedQueueReplay(timedQueueReplay); @@ -286,6 +331,7 @@ RewardOptions copyForDispatch() { */ RewardOptions copyForNestedDispatch(String replayKey) { RewardOptions copy = copyForDispatch(); + copy.legacyFailureObserverCapture = null; copy.setCompletedAsyncInjections(0); copy.setAsyncReplayProgress(new HashMap<>()); // The checkpoint consumer belongs to the queued parent occurrence. Nested diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java index 3a48c82721..437d212e30 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/builtin/RewardCommands.java @@ -261,14 +261,14 @@ public CompletionStage onRewardRequestedAsync(Reward reward, AdvancedCor // Validate the player before any mixed-section side effect, then preserve // the established console-before-player command order. return availability.thenCompose(ignored -> consoleCommands.isEmpty() - && !Reward.hasReplayCommandSnapshot(placeholders, "console") + && !Reward.hasReplayCommandSnapshot(placeholders, "console", replayState, replayKey) ? CompletableFuture.completedFuture(null) - : eligibilityGuard == null + : replayState == null && eligibilityGuard == null ? MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, placeholders, stagger) : MiscUtils.getInstance().executeConsoleCommandsAsync(user.getPlayerName(), consoleCommands, - placeholders, stagger, eligibilityGuard)) - .thenCompose(ignored -> eligibilityGuard == null + placeholders, stagger, eligibilityGuard, replayState, replayKey)) + .thenCompose(ignored -> replayState == null && eligibilityGuard == null ? user.preformCommandAsync(userCommands, placeholders) : user.preformCommandAsync(userCommands, placeholders, eligibilityGuard, replayState, replayKey)) .thenApply(ignored -> null); 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 83e0c85d66..da485821c9 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 @@ -74,6 +74,7 @@ public class AdvancedCoreUser { private static final String ASYNC_RETRY_DELIMITER = "%asyncretry%"; private static final String ASYNC_OCCURRENCE_DELIMITER = "%asyncoccurrence%"; private static final String ASYNC_REPLAY_KEY_PLACEHOLDER = "__AdvancedCoreAsyncReplayKey"; + private static final String ASYNC_REPLAY_PROVENANCE = "__AdvancedCoreReplayProvenance"; private static final String CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/1/"; private static final String CHECKPOINTED_CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/2/"; private static final String CONSUMED_CHOICE_OCCURRENCE_PREFIX = "\\AdvancedCoreChoice/3/"; @@ -776,6 +777,17 @@ public void addOfflineRewards(Reward reward, HashMap placeholder HashMap savedPlaceholders = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); if (options != null) savedPlaceholders.putAll(options.getPlaceholders()); + if (options == null || options.isFreshAsyncReplayEntry()) { + Reward.clearReplayMetadata(savedPlaceholders); + if (options != null && options.getAsyncReplayState() != null) + options.getAsyncReplayState().mergeReplayMetadataInto(savedPlaceholders); + savedPlaceholders.put(ASYNC_REPLAY_PROVENANCE, "trusted"); + } else { + // An externally supplied marker cannot claim queue provenance. + savedPlaceholders.remove(ASYNC_REPLAY_PROVENANCE); + if (options.isRestoredAsyncReplayEntry()) + savedPlaceholders.put(ASYNC_REPLAY_PROVENANCE, "trusted"); + } // The caller may supply a separate map. Filter it against the same // replay state after merging, before it becomes a durable queue entry. if (options != null && options.getAsyncReplayState() != null) @@ -835,8 +847,10 @@ public void addTimedReward(Reward reward, HashMap placeholders, HashMap timed = new HashMap<>(getTimedRewards()); String rewardName = queuedRewardReference(reward); rewardName += "%extime%" + System.currentTimeMillis(); - - timed.put(rewardName + "%placeholders%" + ArrayUtils.makeString(placeholders), epochMilli); + HashMap fresh = placeholders == null ? new HashMap<>() : new HashMap<>(placeholders); + Reward.clearReplayMetadata(fresh); + fresh.put(ASYNC_REPLAY_PROVENANCE, "trusted"); + timed.put(rewardName + "%placeholders%" + ArrayUtils.makeString(fresh), epochMilli); setTimedRewards(timed); } loadTimedDelayedTimer(epochMilli); @@ -1058,7 +1072,11 @@ private static String withAsyncReplayProgress(String rewardEntry, Throwable fail int placeholders = rewardEntry.indexOf("%placeholders%"); String storedReference = placeholders < 0 ? rewardEntry : rewardEntry.substring(0, placeholders); String suffix = placeholders < 0 ? "" : rewardEntry.substring(placeholders); - if (replayFailure != null) suffix = "%placeholders%" + ArrayUtils.makeString(replayFailure.getReplayPlaceholders()); + if (replayFailure != null) { + HashMap saved = new HashMap<>(replayFailure.getReplayPlaceholders()); + saved.put(ASYNC_REPLAY_PROVENANCE, "checkpoint"); + suffix = "%placeholders%" + ArrayUtils.makeString(saved); + } // A nondeterministic decision may be durable work before the first effect. // Preserve its snapshot without inventing a completed-stage marker. if (completed <= 0 && serializedProgress.isEmpty()) return replayFailure == null @@ -1075,8 +1093,10 @@ private static String withAsyncReplayProgress(String rewardEntry, Reward.ReplayC String reference = marker < 0 ? rewardEntry : rewardEntry.substring(0, marker); QueuedReplay queuedReplay = parseQueuedReplay(stripAsyncRetryMarker(reference)); String serialized = encodeAsyncReplayProgress(checkpoint.getReplayProgress(), checkpoint.getReplayRegistryFingerprints()); + HashMap checkpointPlaceholders = new HashMap<>(checkpoint.getPlaceholders()); + checkpointPlaceholders.put(ASYNC_REPLAY_PROVENANCE, "checkpoint"); return queuedReference(queuedReplay) + (serialized.isEmpty() ? "" : ASYNC_PROGRESS_DELIMITER + serialized) - + "%placeholders%" + ArrayUtils.makeString(checkpoint.getPlaceholders()); + + "%placeholders%" + ArrayUtils.makeString(checkpointPlaceholders); } private static int completedAsyncInjections(Throwable failure) { @@ -1259,6 +1279,8 @@ private void checkAdmittedDelayedTimedRewards(ReplayPlayerState capturedState) { replayOptions.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); replayOptions.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); replayOptions.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); + String provenance = replayOptions.getPlaceholders().remove(ASYNC_REPLAY_PROVENANCE); + replayOptions.setRestoredAsyncReplayEntry("trusted".equals(provenance) || "checkpoint".equals(provenance)); replayOptions.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); replayOptions.setTimedQueueReplay(true); replayOptions.addPlaceholder("date", @@ -1440,6 +1462,8 @@ private CompletionStage dispatchAdmittedOfflineRewards(boolean force, Repl options.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); options.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); options.setLegacyAsyncReplayCheckpoint(queuedReplay.legacyAsyncReplayCheckpoint); + String provenance = options.getPlaceholders().remove(ASYNC_REPLAY_PROVENANCE); + options.setRestoredAsyncReplayEntry("trusted".equals(provenance) || "checkpoint".equals(provenance)); options.setAsyncReplayOccurrenceId(queuedReplay.asyncReplayOccurrenceId); AtomicReference currentEntry = new AtomicReference<>(rewardEntry); options.setAsyncReplayCheckpointConsumer(checkpoint -> checkpointOfflineReward(currentEntry, checkpoint)); @@ -1932,11 +1956,12 @@ public int getNumberOfDaysSinceLogin() { */ @SuppressWarnings("deprecation") public OfflinePlayer getOfflinePlayer() { - if (getPlayerName().isBlank()) { + String name = getPlayerName(); + if (name == null || name.isBlank()) { return null; } if (!plugin.getOptions().isOnlineMode()) { - return Bukkit.getOfflinePlayer(getPlayerName()); + return Bukkit.getOfflinePlayer(name); } if (uuid != null && !uuid.equals("")) { return Bukkit.getOfflinePlayer(java.util.UUID.fromString(uuid)); @@ -1960,7 +1985,8 @@ public ArrayList getOfflineRewards() { */ public Player getPlayer() { if (!plugin.getOptions().isOnlineMode()) { - return Bukkit.getPlayer(getPlayerName()); + String name = getPlayerName(); + return name == null || name.isBlank() ? null : Bukkit.getPlayer(name); } if (uuid != null && !uuid.isEmpty()) { return Bukkit.getPlayer(java.util.UUID.fromString(uuid)); 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 71e22301b0..48650d1b2c 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 @@ -129,6 +129,13 @@ public int getInt(String key, int def, boolean useCache, boolean waitForCache) { UserDataFetchMode.fromBooleans(useCache, waitForCache)); } + /** + * Read an integer using the requested cache and storage policy. When the + * policy permits storage lookup, a cold legacy cache can perform synchronous + * storage access on the calling thread. Callers that must not block a + * platform thread must use a cache-only policy or perform the read on a + * worker; preloading alone cannot guarantee a later cache hit. + */ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mode) { if (key == null || key.isEmpty()) { return def; @@ -549,6 +556,7 @@ public void setInt(final UserStorage storage, final String key, final int value, .usesSharedSqlStorage(storage); if (queue || storage == effectiveStorageType()) { UserDataCache target = queue ? cacheForRead(UserDataFetchMode.CACHE_ONLY) : user.getCache(); + if (target == null) throw new IllegalStateException("User cache was removed before the queued change was admitted"); if (queue || flushImmediately || target.matchesLegacyStorage(storage)) { if (!queue && !flushImmediately) UserDataCache.runWithLegacyDirectPublication(directManager, captured, target, () -> target.addChange(change, false)); else target.addChange(change, true); @@ -712,6 +720,7 @@ public void setString(final UserStorage storage, final String key, final String .usesSharedSqlStorage(storage); if (queue || storage == effectiveStorageType()) { UserDataCache target = queue ? cacheForRead(UserDataFetchMode.CACHE_ONLY) : user.getCache(); + if (target == null) throw new IllegalStateException("User cache was removed before the queued change was admitted"); if (queue || flushImmediately || target.matchesLegacyStorage(storage)) { if (!queue && !flushImmediately) UserDataCache.runWithLegacyDirectPublication(directManager, captured, target, () -> target.addChange(change, false)); else target.addChange(change, true); 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 1ff1a388a9..9d16c5ce4f 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 @@ -48,6 +48,7 @@ private record LegacyBulkOverride(DataValue value, long version, boolean sqlite) private int inFlightBatches = 0; private volatile Consumer> sharedStorageWriter; private com.bencodez.advancedcore.api.user.UserStorage sharedStorageType; + private Boolean sharedMysqlFold; private Thread sharedBatchThread; private volatile Consumer sharedFlushGate; private volatile Consumer sharedExclusiveFlushGate; @@ -716,12 +717,21 @@ public IntegerMutationResult tryMutateIntegers(java.util.Map op UUID identity = uuid; boolean admission = manager != null && identity != null && manager.hasSharedRuntimeLifecycle(); if (admission && !manager.beginSharedSynchronousIntegerMutation(identity)) return null; - try { return tryMutateIntegersAdmitted(operands, operation, notification); } + java.util.List notifications = new java.util.ArrayList<>(1); + IntegerMutationResult result; + try { result = tryMutateIntegersAdmitted(operands, operation, notification, notifications); } finally { if (admission) manager.endSharedSynchronousIntegerMutation(identity); } + for (Runnable notify : notifications) { + try { manager.dispatchSharedMutationNotification(notify); } + catch (RuntimeException | Error failure) { + try { manager.recordSharedStorageFailure(failure); } catch (RuntimeException | Error ignored) { } + } + } + return result; } private IntegerMutationResult tryMutateIntegersAdmitted(java.util.Map operands, - IntegerMutation operation, java.util.function.Consumer> notification) { + IntegerMutation operation, java.util.function.Consumer> notification, java.util.List notifications) { Objects.requireNonNull(operands, "operands"); Objects.requireNonNull(operation, "operation"); if (operands.isEmpty() || operands.size() > 64) throw new IllegalArgumentException("Invalid integer mutation size"); java.util.LinkedHashMap ordered = new java.util.LinkedHashMap(); @@ -745,9 +755,11 @@ private IntegerMutationResult tryMutateIntegersAdmitted(java.util.Map identities = new java.util.HashSet(); for (String key : ordered.keySet()) { if (sqlite && !identities.add(sqliteDirectKey(key))) throw new IllegalArgumentException("Duplicate SQLite column identity"); - if (sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && hasAmbiguousMysqlKey(key)) return null; - String actual = sqlite ? resolveSharedSqliteKey(key) : key; + boolean mysql = sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL; + if (mysql && sharedMysqlFold == null && hasAmbiguousMysqlKey(key)) return null; + String actual = sqlite ? resolveSharedSqliteKey(key) : mysql && Boolean.TRUE.equals(sharedMysqlFold) ? resolveSharedMysqlKey(key) : key; if (actual == null) return null; + if (mysql && Boolean.TRUE.equals(sharedMysqlFold) && !identities.add(actual)) throw new IllegalArgumentException("Duplicate MySQL column identity"); actualKeys.put(key, actual); } var previous = new java.util.LinkedHashMap(); @@ -808,15 +820,7 @@ else if (value.isString()) { } } } - if (notify != null) { - try { manager.dispatchSharedUserDataNotification(notify); } - catch (RuntimeException | Error failure) { - // The mutation is already accepted; a failed notification cannot - // turn it into a retryable failure. - try { manager.recordSharedStorageFailure(failure); } - catch (RuntimeException | Error ignored) { } - } - } + if (notify != null) notifications.add(notify); return result; } @@ -921,7 +925,13 @@ public IntegerMutationResult tryMutateLegacyIntegers(java.util.Map secondVersion; @@ -1425,12 +1443,17 @@ public synchronized void configureSharedStorage(Consumer> writer, Consumer gate, Consumer exclusiveGate, com.bencodez.advancedcore.api.user.UserStorage storageType) { + configureSharedStorage(writer, gate, exclusiveGate, storageType, null); + } + public synchronized void configureSharedStorage(Consumer> writer, Consumer gate, + Consumer exclusiveGate, com.bencodez.advancedcore.api.user.UserStorage storageType, Boolean mysqlFold) { if (sharedFlushGate != null && sharedFlushGate != gate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); if (sharedExclusiveFlushGate != null && sharedExclusiveFlushGate != exclusiveGate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); if (sharedStorageType != null && storageType != null && sharedStorageType != storageType) throw new IllegalStateException("Shared user cache belongs to another storage generation"); if (sharedFlushGate == null && gate != null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); setSharedStorageWriter(writer); if (storageType != null) sharedStorageType = storageType; + sharedMysqlFold = mysqlFold; sharedFlushGate = gate; sharedExclusiveFlushGate = exclusiveGate; } @@ -1569,7 +1592,7 @@ private Runnable processChangesInternal(boolean admitted, UserDataManager.Legacy value = resolveLegacyBulkValue(key, value, queuedVersion); physicalCandidateVersions.merge(key, effectiveLegacyQueuedVersion(key, queuedVersion), Math::max); } - inFlightValues.put(key, value); + putSnapshotValue(inFlightValues, key, value); } } catch (RuntimeException | Error preparationFailure) { inFlightValues.clear(); @@ -1600,11 +1623,17 @@ private Runnable processChangesInternal(boolean admitted, UserDataManager.Legacy String key = change.getKey(); boolean legacyChange = writer == null || legacyBatchChanges.contains(change); String physical = null; - if (legacyChange && key != null) { - if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) physical = sqliteDirectKey(key); + boolean sharedMysql = writer != null && sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && Boolean.TRUE.equals(sharedMysqlFold); + if ((legacyChange || sharedMysql) && key != null) { + if (sharedMysql) physical = key.toLowerCase(java.util.Locale.ROOT); + else if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) physical = sqliteDirectKey(key); else if (legacyMysqlFoldsColumns()) physical = key.toLowerCase(java.util.Locale.ROOT); } if (physical == null) values.put(key, legacyChange ? inFlightValues.get(key) : change.toUserDataValue()); + else if (sharedMysql && !legacyChange) { + String canonical = physicalKeys.computeIfAbsent(physical, ignored -> key); + values.put(canonical, change.toUserDataValue()); + } else { long version = physicalCandidateVersions.getOrDefault(key, 0L); if (version >= physicalVersions.getOrDefault(physical, Long.MIN_VALUE)) { @@ -1659,12 +1688,19 @@ private Runnable processChangesInternal(boolean admitted, UserDataManager.Legacy && (queuedVersion == null || Objects.equals(persistedVersion, queuedVersion))) { String key = persistedChange.getKey(); String physical = null; - if ((writer == null || legacyBatchChanges.contains(persistedChange)) && key != null) { - if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) physical = sqliteDirectKey(key); + boolean sharedMysql = writer != null && sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && Boolean.TRUE.equals(sharedMysqlFold); + if ((writer == null || legacyBatchChanges.contains(persistedChange) || sharedMysql) && key != null) { + if (sharedMysql) physical = key.toLowerCase(java.util.Locale.ROOT); + else if (legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE) physical = sqliteDirectKey(key); else if (legacyMysqlFoldsColumns()) physical = key.toLowerCase(java.util.Locale.ROOT); } String winningKey = physical == null ? key : physicalKeys.getOrDefault(physical, key); - cache.put(key, values.get(winningKey)); + DataValue committedValue = values.get(winningKey); + cache.put(key, committedValue); + if (writer != null && sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && Boolean.TRUE.equals(sharedMysqlFold)) { + for (String alias : cache.keySet()) if (alias != null && !alias.equals(key) && alias.equalsIgnoreCase(key) + && (changedAt.get(alias) == null || (persistedVersion != null && Objects.equals(persistedVersion, changedAt.get(alias))))) cache.put(alias, committedValue); + } } } } @@ -1784,6 +1820,10 @@ private synchronized void onScheduledFlushComplete() { public synchronized void updateCache(HashMap tempCache) { cache = tempCache == null ? new HashMap<>() : new HashMap<>(tempCache); + if (sharedFlushGate != null && sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && Boolean.TRUE.equals(sharedMysqlFold)) { + inFlightValues.forEach((key, value) -> putSnapshotValue(cache, key, value)); + if (cachedChanges != null) for (UserDataChange change : cachedChanges) putSnapshotValue(cache, change.getKey(), change.toUserDataValue()); + } completeStorageSnapshot = false; storageSnapshotPublished = true; hasEverPublishedStorageSnapshot = true; hasPublishedStorageSnapshot = true; @@ -1802,8 +1842,8 @@ public synchronized void updateCompleteCachePreservingPending(HashMap storageValues, boolean complete) { 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()); + inFlightValues.forEach((key, value) -> putSnapshotValue(refreshed, key, value)); + if (cachedChanges != null) for (UserDataChange change : cachedChanges) putSnapshotValue(refreshed, change.getKey(), change.toUserDataValue()); cache = refreshed; completeStorageSnapshot = complete; storageSnapshotPublished = true; hasEverPublishedStorageSnapshot = true; @@ -1844,7 +1884,8 @@ private synchronized HashMap updateSharedSnapshot(HashMap { String key = entry.getKey(); long version = entry.getValue(); if ((version > expectedVersion || (!hasPublishedStorageSnapshot && version == expectedVersion && cache.containsKey(key))) @@ -1855,8 +1896,8 @@ private synchronized HashMap updateSharedSnapshot(HashMap merged.put(key, resolveLegacyBulkValue(key, value))); - if (cachedChanges != null) for (UserDataChange pending : cachedChanges) merged.put(pending.getKey(), resolveLegacyBulkValue(pending.getKey(), pending.toUserDataValue(), legacyChangeVersions.get(pending))); + inFlightValues.forEach((key, value) -> putSnapshotValue(merged, key, resolveLegacyBulkValue(key, value))); + if (cachedChanges != null) for (UserDataChange pending : cachedChanges) putSnapshotValue(merged, pending.getKey(), resolveLegacyBulkValue(pending.getKey(), pending.toUserDataValue(), legacyChangeVersions.get(pending))); changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; completeStorageSnapshot = complete; @@ -1875,7 +1916,7 @@ private void recordSnapshotReplacement() { // 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()))) + changedAt.keySet().removeIf(key -> !hasSharedFoldedPendingAlias(key) && (cachedChanges == null || cachedChanges.stream().noneMatch(change -> key.equals(change.getKey()))) && changesAfterExclusiveFlush.stream().noneMatch(change -> key.equals(change.getKey())) && legacyChangesAfterCheckpoint.stream().noneMatch(change -> key.equals(change.getKey()))); if (cache != null) { @@ -1886,7 +1927,7 @@ private void recordSnapshotReplacement() { overlayLegacyPendingChange(change); for (UserDataChange change : changesAfterExclusiveFlush) if (storageSnapshotPublished || cache.containsKey(change.getKey())) - cache.put(change.getKey(), resolveLegacyBulkValue(change.getKey(), change.toUserDataValue())); + putSnapshotValue(cache, change.getKey(), resolveLegacyBulkValue(change.getKey(), change.toUserDataValue())); for (UserDataChange change : legacyChangesAfterCheckpoint) if (storageSnapshotPublished || cache.containsKey(change.getKey())) overlayLegacyPendingChange(change); @@ -1908,9 +1949,23 @@ else if (version == priorVersion) { } } } + private boolean hasSharedFoldedPendingAlias(String key) { + if (key == null || sharedFlushGate == null || sharedStorageType != com.bencodez.advancedcore.api.user.UserStorage.MYSQL || !Boolean.TRUE.equals(sharedMysqlFold)) return false; + return cachedChanges != null && cachedChanges.stream().anyMatch(change -> change.getKey() != null && key.equalsIgnoreCase(change.getKey())); + } + private void putSnapshotValue(HashMap target, String key, DataValue value) { + if (key != null && sharedFlushGate != null && sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && Boolean.TRUE.equals(sharedMysqlFold)) { + for (String alias : target.keySet()) if (alias != null && alias.equalsIgnoreCase(key)) target.put(alias, value); + } + target.put(key, value); + } private void overlayLegacyPendingChange(UserDataChange change) { String key = change.getKey(); DataValue value = resolveLegacyBulkValue(key, change.toUserDataValue(), legacyChangeVersions.get(change)); + if (sharedFlushGate != null && sharedStorageType == com.bencodez.advancedcore.api.user.UserStorage.MYSQL && Boolean.TRUE.equals(sharedMysqlFold)) { + putSnapshotValue(cache, key, value); + return; + } cache.put(key, value); if (key == null || sharedFlushGate != null) return; boolean sqlite = legacyStorageType == com.bencodez.advancedcore.api.user.UserStorage.SQLITE; 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 99f259e7e9..24b7166870 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 @@ -70,17 +70,26 @@ public class UserDataManager { private CompletableFuture sharedIntegerDrain = CompletableFuture.completedFuture(null); private CompletableFuture sharedIntegerRetirement; private final ThreadLocal sharedIntegerCompletion = new ThreadLocal<>(); + private final ThreadLocal sharedNotificationCallback = new ThreadLocal<>(); private final ThreadLocal sharedIntegerSynchronousDepth = ThreadLocal.withInitial(() -> 0); private final ThreadLocal sharedIntegerWorker = new ThreadLocal<>(); private final Map sharedIntegerQueuedUsers = new java.util.HashMap<>(); private final Map sharedIntegerActiveUsers = new java.util.HashMap<>(); private record SharedReplayLease(UUID uuid, String identity) { } private static final class SharedReplayLeaseState { - final SharedUserDataRuntime runtime; int attempts; boolean committed; boolean checkpointed; + final SharedUserDataRuntime runtime; int attempts; int checkpointReservations; boolean committed; boolean checkpointed; SharedReplayLeaseState(SharedUserDataRuntime runtime) { this.runtime = runtime; } } private final Map sharedReplayLeases = new java.util.HashMap<>(); + /** An observation of uncertain work, never a transaction settlement. */ + public record IndeterminateReplay(UUID user, String identity, boolean checkpoint) { } + private final Map, IndeterminateReplay> indeterminateReplays = new java.util.HashMap<>(); + /** Snapshot only: these operations and their owner admissions remain pending. */ + public synchronized java.util.List indeterminateSharedReplays() { + return java.util.List.copyOf(indeterminateReplays.values()); + } private final Set sharedIntegerCompletingUsers = new java.util.HashSet<>(); + private final Map>> sharedCallbackReceipts = new java.util.HashMap<>(); private record DeferredSharedInteger(Runnable action, Consumer reject, MutationReceipt receipt) { } private final Map> sharedIntegerDeferred = new java.util.HashMap<>(); @@ -102,20 +111,49 @@ private record DeferredSharedInteger(Runnable action, Consumer reject java.util.stream.IntStream.range(0, 64).mapToObj(ignored -> new java.util.concurrent.locks.ReentrantLock()) .toArray(java.util.concurrent.locks.ReentrantLock[]::new); private final Map> pendingLegacyIntegers = new java.util.HashMap<>(); + private final Map> legacyCallbackReceipts = new java.util.HashMap<>(); private final Set legacyIntegerCacheRetirements = new java.util.HashSet<>(); private final ThreadLocal legacyIntegerNotification = new ThreadLocal<>(); + private final ThreadLocal legacyIntegerCompletion = new ThreadLocal<>(); private int pendingLegacyIntegerCount; private static final int MAX_PENDING_LEGACY_INTEGERS = 256; + /* Admissions bound the number of outstanding callback deliveries. Never run + * rejected callbacks on the SQL worker: retain their admissions instead. */ + private final java.util.concurrent.ThreadPoolExecutor mutationCallbacks = new java.util.concurrent.ThreadPoolExecutor( + 4, 4, 1, TimeUnit.SECONDS, new java.util.concurrent.ArrayBlockingQueue<>(768), task -> { + Thread thread = new Thread(task, "AdvancedCore-mutation-completion-" + WORKER_SEQUENCE.incrementAndGet()); + thread.setDaemon(true); return thread; + }); private static final int MAX_LEGACY_MUTATION_ATTEMPTS = 200; private java.util.concurrent.locks.ReentrantLock legacyMutationStripe(UUID uuid) { return legacyMutationStripes[(uuid.hashCode() & Integer.MAX_VALUE) % legacyMutationStripes.length]; } /** The accepted mutation owns its outcome; observers cannot cancel or settle it. */ - private static final class MutationReceipt extends CompletableFuture { - private final CompletableFuture finalized = new CompletableFuture<>(); + private static class GuardedMutationFuture extends CompletableFuture { + private final UserDataManager owner; + GuardedMutationFuture(UserDataManager owner) { this.owner = owner; } + private void rejectCompletionThreadWait() { + if (!isDone() && owner != null && (Boolean.TRUE.equals(owner.sharedIntegerCompletion.get()) + || Boolean.TRUE.equals(owner.legacyIntegerCompletion.get()))) + throw new IllegalStateException("A completion callback cannot await its own lifecycle drain"); + } + @Override public T join() { rejectCompletionThreadWait(); return super.join(); } + @Override public T get() throws InterruptedException, java.util.concurrent.ExecutionException { rejectCompletionThreadWait(); return super.get(); } + @Override public T get(long timeout, TimeUnit unit) throws InterruptedException, java.util.concurrent.ExecutionException, java.util.concurrent.TimeoutException { + if (timeout > 0) rejectCompletionThreadWait(); return super.get(timeout, unit); + } + @Override public CompletableFuture newIncompleteFuture() { return new GuardedMutationFuture<>(owner); } + @Override public CompletionStage minimalCompletionStage() { + GuardedMutationFuture view = new GuardedMutationFuture<>(owner); + super.whenComplete((value, failure) -> { if (failure == null) view.complete(value); else view.completeExceptionally(failure); }); + return view; + } + } + private static final class MutationReceipt extends GuardedMutationFuture { + private final GuardedMutationFuture finalized; private final UserDataManager sharedOwner; - private MutationReceipt() { this.sharedOwner = null; } - private MutationReceipt(UserDataManager sharedOwner) { this.sharedOwner = sharedOwner; } + private MutationReceipt(UserDataManager owner, boolean shared) { super(null); this.sharedOwner = shared ? owner : null; this.finalized = new GuardedMutationFuture<>(owner); } + private MutationReceipt(UserDataManager sharedOwner) { this(sharedOwner, true); } @Override public boolean cancel(boolean mayInterruptIfRunning) { return false; } @Override public boolean complete(T value) { return false; } @Override public boolean completeExceptionally(Throwable failure) { return false; } @@ -134,12 +172,13 @@ private static final class PendingLegacyIntegerMutation { final Map values; final UserDataCache.IntegerMutation operation; final Consumer> notification; - final MutationReceipt completion = new MutationReceipt<>(); + final MutationReceipt completion; int attempts; - PendingLegacyIntegerMutation(UUID uuid, AdvancedCorePlugin.UserStorageOwner owner, Map values, + PendingLegacyIntegerMutation(UserDataManager manager, UUID uuid, AdvancedCorePlugin.UserStorageOwner owner, Map values, UserDataCache.IntegerMutation operation, Consumer> notification) { this.uuid = uuid; this.owner = owner; this.values = Map.copyOf(values); this.operation = operation; this.notification = notification; + this.completion = new MutationReceipt<>(manager, false); } } private final Object legacyRetirementAdmission = new Object(); @@ -610,7 +649,7 @@ public final boolean closeSharedRuntimeAsync(Runnable afterRetirement) { */ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable afterRetirement) { Objects.requireNonNull(afterRetirement, "afterRetirement"); - if (Boolean.TRUE.equals(sharedIntegerCompletion.get()) || sharedIntegerSynchronousDepth.get() != 0) + if (Boolean.TRUE.equals(sharedIntegerCompletion.get()) || Boolean.TRUE.equals(sharedNotificationCallback.get()) || sharedIntegerSynchronousDepth.get() != 0) return CompletableFuture.failedFuture(new IllegalStateException("Shared retirement cannot await its own mutation completion")); SharedUserDataRuntime runtime; CompletableFuture completion; @@ -850,6 +889,7 @@ public final T withSharedSqlBackendOrLegacy(Supplier uuid, public UserDataManager(AdvancedCorePlugin plugin) { this.plugin = plugin; + mutationCallbacks.allowCoreThreadTimeOut(true); userDataCache = new ConcurrentHashMap<>(); keys = new ArrayList<>(); intColumns = new ArrayList<>(); @@ -950,7 +990,13 @@ private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall, CacheRefresh } else { UserDataCache data = new UserDataCache(this, uuid); refresh.changed = data.refreshInternal(false, true); - if (data.hasCache()) userDataCache.putIfAbsent(uuid, data); + if (data.hasCache()) { + UserDataCache winner = userDataCache.putIfAbsent(uuid, data); + if (winner != null) { + refresh.flushNotification = winner.clearChangesForRefresh(); + refresh.changed = winner.refreshInternal(false, true); + } + } } return; } @@ -1142,6 +1188,13 @@ private boolean finishSharedCachePopulation(SharedCachePopulation population, bo * The expected instance prevents an old owner from retiring a replacement. */ public final boolean retireSharedCache(UUID uuid, UserDataCache expected) { + return retireSharedCache(uuid, expected, false); + } + /** Atomically verify retirement admission before invalidating the flushed cache. */ + public final boolean retireSharedCacheAfterFlush(UUID uuid, UserDataCache expected) { + return retireSharedCache(uuid, expected, true); + } + private boolean retireSharedCache(UUID uuid, UserDataCache expected, boolean retireContents) { Objects.requireNonNull(uuid, "uuid"); synchronized (legacyMutationAdmission) { if (pendingLegacyIntegers.containsKey(uuid)) return false; @@ -1149,6 +1202,7 @@ public final boolean retireSharedCache(UUID uuid, UserDataCache expected) { ignored -> new SharedCachePopulationState()); synchronized (state) { if (userDataCache.get(uuid) != expected) return false; + if (retireContents && expected != null) expected.retireAfterSharedFlush(); if (expected != null) userDataCache.remove(uuid, expected); sharedCachePopulations.remove(uuid); completedSharedCachePopulations.remove(uuid); @@ -1349,21 +1403,36 @@ private void dispatchPlatformCallback(Runnable notification, org.bukkit.entity.E * cache and per-user admission before a listener can re-enter storage APIs. */ public final void dispatchSharedUserDataNotification(Runnable notification) { - dispatchSharedUserDataNotification(notification, sharedNotificationGeneration.get()); + dispatchSharedUserDataNotification(notification, sharedNotificationGeneration.get(), timer); } + /** Mutation listeners use the completion lane so they cannot stall storage work. */ + public final void dispatchSharedMutationNotification(Runnable notification) { + long generation = sharedNotificationGeneration.get(); + // The bridge runs after the current storage operation has left admission. + // It queues only delivery, leaving the storage worker free for successors. + timer.execute(() -> { + try { dispatchSharedUserDataNotification(notification, generation, mutationCallbacks); } + catch (RuntimeException | Error failure) { try { reportDeferredStorageFailure(failure); } catch (RuntimeException | Error ignored) { } } + }); + } private void dispatchSharedUserDataNotification(Runnable notification, long generation) { + dispatchSharedUserDataNotification(notification, generation, timer); + } + + private void dispatchSharedUserDataNotification(Runnable notification, long generation, java.util.concurrent.Executor executor) { Objects.requireNonNull(notification, "notification"); if (sharedNotificationsClosed) return; SharedSqlRoute admission = sharedSqlRoute; try { - timer.execute(() -> { + executor.execute(() -> { if (sharedNotificationsClosed || generation != sharedNotificationGeneration.get()) return; try { Runnable admittedNotification = () -> { if (sharedNotificationsClosed || generation != sharedNotificationGeneration.get()) return; - try { notification.run(); } + try { sharedNotificationCallback.set(true); notification.run(); } catch (RuntimeException | Error failure) { reportDeferredStorageFailure(failure); } + finally { sharedNotificationCallback.remove(); } }; // Keep the producing runtime alive for the complete callback. A backend // replacement or shutdown takes the matching write admission and must @@ -1430,7 +1499,8 @@ public void clearCache() { * completes fails exceptionally; the caller may retry after the callback returns. */ public CompletionStage clearCacheAsyncCompletion() { - if (UserDataCache.hasLiveLegacyCallback(this) || Boolean.TRUE.equals(legacyIntegerNotification.get())) + if (UserDataCache.hasLiveLegacyCallback(this) || Boolean.TRUE.equals(legacyIntegerNotification.get()) + || Boolean.TRUE.equals(legacyIntegerCompletion.get())) return CompletableFuture.failedFuture(new IllegalStateException("Cache retirement cannot await its own direct-write callback")); CompletableFuture pending; CompletableFuture waiting; @@ -1441,7 +1511,9 @@ public CompletionStage clearCacheAsyncCompletion() { if (activeLegacyIntegerApplications != 0) dependencies.add(legacyIntegerApplicationDrain); synchronized (legacyMutationAdmission) { for (java.util.ArrayDeque queue : pendingLegacyIntegers.values()) - for (com.bencodez.advancedcore.api.user.usercache.UserDataManager.PendingLegacyIntegerMutation mutation : queue) dependencies.add(mutation.completion); + for (com.bencodez.advancedcore.api.user.usercache.UserDataManager.PendingLegacyIntegerMutation mutation : queue) dependencies.add(mutation.completion.finalized); + for (Set callbacks : legacyCallbackReceipts.values()) + for (PendingLegacyIntegerMutation mutation : callbacks) dependencies.add(mutation.completion.finalized); } pending = dependencies.isEmpty() ? null : CompletableFuture.allOf(dependencies.toArray(CompletableFuture[]::new)); if (pending != null) { @@ -1521,14 +1593,16 @@ private void clearCacheNowAdmitted() { // 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. + // Capture the current generations under the map lock. Shared generations + // are marked only after their per-user admission drains, so already-admitted + // setters can complete before retirement starts. java.util.HashMap retiring = new java.util.HashMap<>(); + boolean shared = sharedSqlRoute != null; cacheMapLifecycle.writeLock().lock(); try { plugin.debug("Clearing cache: " + userDataCache.keySet().size()); retiring.putAll(userDataCache); - for (UserDataCache cache : retiring.values()) cache.beginRemoval(); + if (!shared) for (UserDataCache cache : retiring.values()) cache.beginRemoval(); } finally { cacheMapLifecycle.writeLock().unlock(); } try { for (Entry entry : retiring.entrySet()) { @@ -1538,13 +1612,23 @@ private void clearCacheNowAdmitted() { // 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)); + withSharedSqlBackendExclusive(uuid, () -> { + if (shared) { + if (userDataCache.get(uuid) != cache) return; + cache.beginRemoval(); + } + try { clearCacheExclusively(uuid, cache); } + catch (RuntimeException | Error failure) { + if (shared && userDataCache.get(uuid) == cache) cache.cancelRemoval(); + throw failure; + } + }); } } 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(); + if (!shared) for (UserDataCache cache : retiring.values()) cache.cancelRemoval(); throw failure; } } @@ -1798,13 +1882,17 @@ private void notifyLegacyIntegerMutation(UserDataCache.IntegerMutationResult mut * completed; callers must retain and observe it for the actual operation outcome. * Cancelling a dependent stage only cancels that observation, not this receipt * or the mutation, and must not be treated as a reason to retry the mutation. + * Completion callbacks registered before settlement run on a bounded lane, + * independently of the serialized storage worker; callbacks registered after + * settlement may run on the registering thread. Delivery can be out of order. + * Blocking callbacks can exhaust that lane and delay completion and shutdown. */ public CompletionStage submitLegacyIntegerMutation(UUID uuid, Map values, UserDataCache.IntegerMutation operation, Consumer> notification) { Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(values, "values"); Objects.requireNonNull(operation, "operation"); if (values.isEmpty() || values.size() > 64) throw new IllegalArgumentException("Invalid integer mutation size"); com.bencodez.advancedcore.AdvancedCorePlugin.UserStorageOwner owner = Objects.requireNonNull(plugin.getNativeUserStorageOwner(), "Native user storage owner is unavailable"); - PendingLegacyIntegerMutation pending = new PendingLegacyIntegerMutation(uuid, owner, values, operation, notification); + PendingLegacyIntegerMutation pending = new PendingLegacyIntegerMutation(this, uuid, owner, values, operation, notification); boolean start; java.util.concurrent.locks.ReentrantLock stripe = legacyMutationStripe(uuid); if (!stripe.tryLock()) throw new RejectedExecutionException("Legacy integer mutation admission is busy"); @@ -1825,7 +1913,10 @@ public CompletionStage submitLegacyIntegerM return pending.completion; } - /** Submit one shared-cache mutation and retain its receipt through lifecycle retirement. */ + /** Submit one shared-cache mutation and retain its receipt through lifecycle retirement. + * Completion callbacks registered before settlement run on a bounded lane and can + * arrive out of order; callbacks attached afterward may run on the attaching thread. + * Blocking callbacks can exhaust the lane and delay completion and shutdown. */ public CompletionStage submitSharedIntegerMutation(UUID uuid, Map values, UserDataCache.IntegerMutation operation, Consumer> notification) { Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(values, "values"); Objects.requireNonNull(operation, "operation"); @@ -1876,31 +1967,41 @@ private void runSharedIntegerMutation(SharedUserDataRuntime runtime, UUID uuid, } private void finishSharedIntegerMutation(UUID uuid, MutationReceipt receipt, T result, Throwable failure) { + DeferredSharedInteger failedTask = null; Throwable schedulingFailure = null; synchronized (this) { java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); if (queue == null || queue.isEmpty() || queue.peekFirst().receipt() != receipt) throw new IllegalStateException("Shared integer completion is out of order"); queue.removeFirst(); if (queue.isEmpty()) sharedIntegerDeferred.remove(uuid); - sharedIntegerCompletingUsers.add(uuid); + sharedCallbackReceipts.computeIfAbsent(uuid, ignored -> new java.util.HashSet<>()).add(receipt); sharedIntegerQueuedUsers.computeIfPresent(uuid, (key, count) -> count == 1 ? null : count - 1); - } - sharedIntegerCompletion.set(true); - try { if (failure == null) receipt.succeed(result); else receipt.fail(failure); } - finally { sharedIntegerCompletion.remove(); } - DeferredSharedInteger failedTask = null; Throwable schedulingFailure = null; - synchronized (this) { - sharedIntegerCompletingUsers.remove(uuid); - java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); + queue = sharedIntegerDeferred.get(uuid); if (queue != null && !queue.isEmpty() && !sharedIntegerActiveUsers.containsKey(uuid)) { - com.bencodez.advancedcore.api.user.usercache.UserDataManager.DeferredSharedInteger next = queue.peekFirst(); + DeferredSharedInteger next = queue.peekFirst(); try { timer.execute(next.action()); } catch (RuntimeException | Error error) { failedTask = next; schedulingFailure = error; } } - if (--sharedIntegerAdmissions == 0) sharedIntegerDrain.complete(null); } if (failedTask != null) failedTask.reject().accept(schedulingFailure); - receipt.finalized.complete(null); + try { + mutationCallbacks.execute(() -> { + sharedIntegerCompletion.set(true); + try { if (failure == null) receipt.succeed(result); else receipt.fail(failure); } + finally { + sharedIntegerCompletion.remove(); + synchronized (this) { + Set> callbacks = sharedCallbackReceipts.get(uuid); + callbacks.remove(receipt); if (callbacks.isEmpty()) sharedCallbackReceipts.remove(uuid); + if (--sharedIntegerAdmissions == 0) sharedIntegerDrain.complete(null); + } + sharedIntegerCompletion.set(true); + try { receipt.finalized.complete(null); } finally { sharedIntegerCompletion.remove(); } + } + }); + } catch (RuntimeException | Error rejected) { + try { reportDeferredStorageFailure(rejected); } catch (RuntimeException | Error ignored) { } + } } /** Observe when a shared mutation's completion callbacks and admission have settled. */ @@ -1919,19 +2020,20 @@ public CompletionStage afterSharedMutationFinalization(CompletionStage * {@link SqlUserStorage.ReplayPreconditionException} only before any write or * external effect to report a definite rejection without retry. */ - public CompletionStage submitSharedReplayTransaction(UUID uuid, UserStorage storage, + /** Submit an award with its durable identity for indeterminate diagnostics. */ + public CompletionStage submitSharedReplayTransaction(UUID uuid, UserStorage storage, String identity, Map initialValues, SqlUserStorage.TransactionWork work) { - return submitSharedReplayTransaction(uuid, storage, initialValues, work, null); + return submitSharedReplayTransaction(uuid, storage, initialValues, work, null, Objects.requireNonNull(identity, "identity")); } /** Submit checkpoint cleanup for an existing lease even while shutdown is draining it. */ public CompletionStage submitSharedReplayCheckpoint(UUID uuid, UserStorage storage, String identity, Map initialValues, SqlUserStorage.TransactionWork work) { - return submitSharedReplayTransaction(uuid, storage, initialValues, work, Objects.requireNonNull(identity, "identity")); + return submitSharedReplayTransaction(uuid, storage, initialValues, work, Objects.requireNonNull(identity, "identity"), identity); } private CompletionStage submitSharedReplayTransaction(UUID uuid, UserStorage storage, - Map initialValues, SqlUserStorage.TransactionWork work, String checkpointIdentity) { + Map initialValues, SqlUserStorage.TransactionWork work, String checkpointIdentity, String diagnosticIdentity) { Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(storage, "storage"); Objects.requireNonNull(work, "work"); java.util.Map initial = Map.copyOf(initialValues); MutationReceipt receipt = new MutationReceipt(this); SharedUserDataRuntime runtime; Throwable schedulingFailure = null; @@ -1939,6 +2041,8 @@ private CompletionStage submitSharedReplayTransaction(UUID uuid, UserStor runtime = sharedRuntime; com.bencodez.advancedcore.api.user.usercache.UserDataManager.SharedReplayLeaseState checkpointLease = checkpointIdentity == null ? null : sharedReplayLeases.get(new SharedReplayLease(uuid, checkpointIdentity)); boolean drainingCheckpoint = checkpointLease != null && runtime != null && checkpointLease.runtime == runtime; + if (checkpointIdentity != null && (!drainingCheckpoint || checkpointLease.checkpointReservations == 0)) + throw new IllegalStateException("Replay checkpoint requires an active lease for its storage owner"); if (runtime == null || sharedRuntimeRetiring || sharedRuntimeReplacing || (sharedIntegerClosing && !drainingCheckpoint) || sharedIntegerOwnerReplacement) throw new RejectedExecutionException("Shared user storage is unavailable or retiring"); if (!runtime.backend().supportsAtomicUserTransactions()) @@ -1947,10 +2051,11 @@ private CompletionStage submitSharedReplayTransaction(UUID uuid, UserStor // leases cannot prevent the checkpoints that release those leases. int limit = drainingCheckpoint ? MAX_PENDING_LEGACY_INTEGERS * 2 : MAX_PENDING_LEGACY_INTEGERS; if (sharedIntegerAdmissions >= limit) throw new RejectedExecutionException("Shared mutation queue is full"); + if (checkpointIdentity != null) checkpointLease.checkpointReservations--; if (sharedIntegerAdmissions++ == 0) sharedIntegerDrain = new CompletableFuture<>(); sharedIntegerQueuedUsers.merge(uuid, 1, Integer::sum); DeferredSharedInteger deferred = new DeferredSharedInteger( - () -> runSharedReplayTransaction(runtime, uuid, storage, initial, work, receipt, 0), + () -> runSharedReplayTransaction(runtime, uuid, storage, initial, work, receipt, 0, diagnosticIdentity, checkpointIdentity != null), failure -> finishSharedIntegerMutation(uuid, receipt, null, failure), receipt); java.util.ArrayDeque queue = sharedIntegerDeferred.computeIfAbsent(uuid, ignored -> new java.util.ArrayDeque<>()); queue.addLast(deferred); @@ -1977,14 +2082,28 @@ private void acquireSharedReplayLease(UUID uuid, String identity, boolean checkp if (existing != null) { if (existing.runtime != sharedRuntime) throw new IllegalStateException("Replay owner changed"); if (existing.checkpointed && !checkpoint) throw new IllegalStateException("Replay award is already checkpointed"); - existing.attempts++; + existing.attempts++; if (checkpoint) existing.checkpointReservations++; return; } if (sharedRuntime == null || sharedRuntimeRetiring || sharedRuntimeReplacing || sharedIntegerClosing || sharedIntegerOwnerReplacement) throw new RejectedExecutionException("Shared user storage is unavailable or retiring"); if (sharedIntegerAdmissions >= MAX_PENDING_LEGACY_INTEGERS) throw new RejectedExecutionException("Shared mutation queue is full"); if (sharedIntegerAdmissions++ == 0) sharedIntegerDrain = new CompletableFuture<>(); - SharedReplayLeaseState state = new SharedReplayLeaseState(sharedRuntime); state.attempts = 1; sharedReplayLeases.put(key, state); + SharedReplayLeaseState state = new SharedReplayLeaseState(sharedRuntime); state.attempts = 1; + if (checkpoint) state.checkpointReservations = 1; sharedReplayLeases.put(key, state); + } + + /** Release a checkpoint reservation only when submission did not admit work. */ + public synchronized void cancelSharedReplayCheckpointReservation(UUID uuid, String identity) { + SharedReplayLease key = new SharedReplayLease(Objects.requireNonNull(uuid), Objects.requireNonNull(identity)); + SharedReplayLeaseState state = sharedReplayLeases.get(key); + if (state == null || state.checkpointReservations == 0 || state.attempts == 0) + throw new IllegalStateException("Replay checkpoint reservation is not active"); + state.checkpointReservations--; state.attempts--; + if (state.attempts == 0 && (!state.committed || state.checkpointed)) { + sharedReplayLeases.remove(key); + if (--sharedIntegerAdmissions == 0) sharedIntegerDrain.complete(null); + } } /** Settle one lease attempt; a committed award retains the owner until checkpoint cleanup. */ @@ -2002,11 +2121,22 @@ public void settleSharedReplayLease(UUID uuid, String identity, boolean awardCom } private void runSharedReplayTransaction(SharedUserDataRuntime runtime, UUID uuid, UserStorage storage, - Map initial, SqlUserStorage.TransactionWork work, MutationReceipt receipt, int attempts) { + Map initial, SqlUserStorage.TransactionWork work, MutationReceipt receipt, int attempts, + String identity, boolean checkpoint) { T result; try { if (sharedRuntime != runtime) throw new IllegalStateException("Shared storage owner changed"); result = runtime.transaction(uuid, storage, initial, work); + if (checkpoint) { + // The durable deletion is confirmed. Fence previously admitted awards + // before releasing the per-user queue, independent of user callbacks. + synchronized (this) { + SharedReplayLeaseState lease = sharedReplayLeases.get(new SharedReplayLease(uuid, identity)); + if (lease == null || lease.runtime != runtime) + throw new IllegalStateException("Confirmed checkpoint lost its storage lease"); + lease.checkpointed = true; + } + } } catch (SqlUserStorage.ReplayPreconditionException rejected) { finishSharedIntegerMutation(uuid, receipt, null, rejected); return; @@ -2015,10 +2145,16 @@ private void runSharedReplayTransaction(SharedUserDataRuntime runtime, UUID try { reportDeferredStorageFailure(failure); } catch (RuntimeException | Error ignored) { } int next = attempts == Integer.MAX_VALUE ? attempts : attempts + 1; long delay = Math.min(30000L, 250L << Math.min(attempts, 7)); - try { timer.schedule(() -> runSharedReplayTransaction(runtime, uuid, storage, initial, work, receipt, next), delay, TimeUnit.MILLISECONDS); } - catch (RuntimeException | Error rejected) { try { reportDeferredStorageFailure(rejected); } catch (RuntimeException | Error ignored) { } } + try { timer.schedule(() -> runSharedReplayTransaction(runtime, uuid, storage, initial, work, receipt, next, identity, checkpoint), delay, TimeUnit.MILLISECONDS); } + catch (RuntimeException | Error rejected) { + synchronized (this) { indeterminateReplays.put(receipt, new IndeterminateReplay(uuid, identity, checkpoint)); } + try { reportDeferredStorageFailure(rejected); } catch (RuntimeException | Error ignored) { } + } return; - } catch (Error uncertain) { try { reportDeferredStorageFailure(uncertain); } catch (RuntimeException | Error ignored) { } return; } + } catch (Error uncertain) { + synchronized (this) { indeterminateReplays.put(receipt, new IndeterminateReplay(uuid, identity, checkpoint)); } + try { reportDeferredStorageFailure(uncertain); } catch (RuntimeException | Error ignored) { } return; + } finishSharedIntegerMutation(uuid, receipt, result, null); } @@ -2085,8 +2221,11 @@ public CompletionStage pendingLegacyIntegerMutations(UUID uuid) { Objects.requireNonNull(uuid, "uuid"); synchronized (legacyMutationAdmission) { java.util.ArrayDeque queue = pendingLegacyIntegers.get(uuid); - if (queue == null) return CompletableFuture.completedFuture(null); - return CompletableFuture.allOf(queue.stream().map(item -> item.completion).toArray(CompletableFuture[]::new)).minimalCompletionStage(); + java.util.ArrayList> pending = new java.util.ArrayList<>(); + if (queue != null) for (PendingLegacyIntegerMutation item : queue) pending.add(item.completion.finalized); + Set callbacks = legacyCallbackReceipts.get(uuid); + if (callbacks != null) for (PendingLegacyIntegerMutation item : callbacks) pending.add(item.completion.finalized); + return guardedObservation(CompletableFuture.allOf(pending.toArray(CompletableFuture[]::new))); } } @@ -2095,16 +2234,27 @@ public CompletionStage pendingSharedIntegerMutations(UUID uuid) { Objects.requireNonNull(uuid, "uuid"); synchronized (this) { java.util.ArrayDeque queue = sharedIntegerDeferred.get(uuid); - if (queue == null || queue.isEmpty()) return CompletableFuture.completedFuture(null).minimalCompletionStage(); - return CompletableFuture.allOf(queue.stream().map(DeferredSharedInteger::receipt).toArray(CompletableFuture[]::new)).minimalCompletionStage(); + java.util.ArrayList> pending = new java.util.ArrayList<>(); + if (queue != null) for (DeferredSharedInteger item : queue) { + pending.add(item.receipt()); pending.add(item.receipt().finalized); + } + Set> callbacks = sharedCallbackReceipts.get(uuid); + if (callbacks != null) for (MutationReceipt item : callbacks) { pending.add(item); pending.add(item.finalized); } + return guardedObservation(CompletableFuture.allOf(pending.toArray(CompletableFuture[]::new))); } } + private CompletionStage guardedObservation(CompletableFuture source) { + GuardedMutationFuture view = new GuardedMutationFuture<>(this); + source.whenComplete((value, failure) -> { if (failure == null) view.complete(value); else view.completeExceptionally(failure); }); + return view; + } /** Close new legacy arithmetic admission before the storage owner is shut down. */ public CompletionStage shutdownLegacyIntegerMutations() { - closeLegacyIntegerAdmissionForShutdown(); - if (UserDataCache.hasLiveLegacyCallback(this) || Boolean.TRUE.equals(legacyIntegerNotification.get())) + if (UserDataCache.hasLiveLegacyCallback(this) || Boolean.TRUE.equals(legacyIntegerNotification.get()) + || Boolean.TRUE.equals(legacyIntegerCompletion.get())) return CompletableFuture.failedFuture(new IllegalStateException("Cache retirement cannot await its own direct-write callback")); + closeLegacyIntegerAdmissionForShutdown(); CompletableFuture completion = new CompletableFuture<>(); try { timer.execute(() -> { @@ -2188,29 +2338,43 @@ private void requirePendingLegacyOwner(PendingLegacyIntegerMutation pending) { private void finishLegacyIntegerMutation(PendingLegacyIntegerMutation pending, UserDataCache.IntegerMutationResult result, Throwable failure) { - if (failure == null) { - // Keep the ticket admitted until its one notification attempt finishes. - // A listener that requests retirement sees the pending ticket and fails - // rather than detaching its cache before notification completes. - legacyIntegerNotification.set(true); - try { notifyLegacyIntegerMutation(result, pending.operation, pending.notification); } - catch (RuntimeException | Error notificationFailure) { - try { reportDeferredStorageFailure(notificationFailure); } catch (RuntimeException | Error ignored) { } - } - finally { legacyIntegerNotification.remove(); } - } PendingLegacyIntegerMutation next; synchronized (legacyMutationAdmission) { java.util.ArrayDeque queue = pendingLegacyIntegers.get(pending.uuid); if (queue == null || queue.peekFirst() != pending) return; - queue.removeFirst(); pendingLegacyIntegerCount--; + queue.removeFirst(); + legacyCallbackReceipts.computeIfAbsent(pending.uuid, ignored -> new java.util.HashSet<>()).add(pending); next = queue.peekFirst(); if (next == null) pendingLegacyIntegers.remove(pending.uuid); } - endLegacyCacheBatch(); - if (failure == null) { - pending.completion.succeed(result); - } else pending.completion.fail(failure); if (next != null) scheduleLegacyIntegerMutation(next, 0); + try { + mutationCallbacks.execute(() -> { + legacyIntegerCompletion.set(true); + try { + if (failure == null) { + legacyIntegerNotification.set(true); + try { notifyLegacyIntegerMutation(result, pending.operation, pending.notification); } + catch (RuntimeException | Error notificationFailure) { + try { reportDeferredStorageFailure(notificationFailure); } catch (RuntimeException | Error ignored) { } + } finally { legacyIntegerNotification.remove(); } + pending.completion.succeed(result); + } else pending.completion.fail(failure); + } + finally { + legacyIntegerCompletion.remove(); + endLegacyCacheBatch(); + synchronized (legacyMutationAdmission) { + Set callbacks = legacyCallbackReceipts.get(pending.uuid); + callbacks.remove(pending); if (callbacks.isEmpty()) legacyCallbackReceipts.remove(pending.uuid); + pendingLegacyIntegerCount--; + } + if (failure == null) pending.completion.finalized.complete(null); + else pending.completion.finalized.completeExceptionally(failure); + } + }); + } catch (RuntimeException | Error rejected) { + try { reportDeferredStorageFailure(rejected); } catch (RuntimeException | Error ignored) { } + } } /** Fence a direct legacy writer against stale row publication. */ diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java index ddce3e604a..1d446da4eb 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java @@ -152,7 +152,7 @@ private void bind(UserDataCache cache, UUID uuid) { throw new IllegalStateException("Shared SQL backend is unavailable"); } selected.user(uuid).writeValues(selected.storageType(), values); - }, cacheGate(uuid), exclusiveCacheGate(uuid), binding.storageType()); + }, cacheGate(uuid), exclusiveCacheGate(uuid), binding.storageType(), binding.caseInsensitiveColumnNames()); } @Override public void requireBlockingAllowed() { @@ -318,13 +318,10 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp @Override public void remove(UUID uuid) { UserDataCache cache = manager.getUserDataCache().get(uuid); - if (cache != null) { - cache.retireAfterSharedFlush(); - } - if (manager.retireSharedCache(uuid, cache)) { + if (manager.retireSharedCacheAfterFlush(uuid, cache)) { cacheGates.remove(uuid); exclusiveCacheGates.remove(uuid); - } + } else throw new IllegalStateException("Shared cache could not be retired"); } @Override public void clearAfterFlush() { for (UUID uuid : cachedUsers()) remove(uuid); } 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 index 98754af8b1..dadd1b8cdc 100644 --- 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 @@ -69,6 +69,13 @@ public BukkitSqlUserBackend(AdvancedCorePlugin plugin, UserStorage storageType, } @Override public UserStorage storageType() { return storageType; } + @Override public Boolean caseInsensitiveColumnNames() { + if (storageType != UserStorage.MYSQL) return null; + Boolean folded = mysql.caseInsensitiveColumnNames(); + // Registered-schema lookup folds names even when PostgreSQL's physical + // identifiers are case sensitive; leave the mixed rule unresolved. + return Boolean.TRUE.equals(folded) ? Boolean.TRUE : null; + } @Override public boolean supportsAtomicUserTransactions() { return storageType == UserStorage.MYSQL ? mysql().supportsAtomicReplayTransactions() : table().supportsAtomicReplayTransactions(); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java index 2cb40bfb84..30512117f7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/MysqlUserBackend.java @@ -52,6 +52,13 @@ public MysqlUserBackend(String baseTableName, MysqlConfig config, SqlUserSchema } @Override public UserStorage storageType() { return UserStorage.MYSQL; } + @Override public Boolean caseInsensitiveColumnNames() { + DbType type = table.getMysql().getConnectionManager().getDbType(); + if (type == DbType.MYSQL || type == DbType.MARIADB) return Boolean.TRUE; + // Schema lookup itself folds registered identifiers even on PostgreSQL. + // A single database-wide rule cannot describe both registered and dynamic keys. + return null; + } @Override public boolean hasAuthoritativeRowReads() { return true; } @Override public boolean supportsAtomicUserTransactions() { return true; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java index 6543e03b31..ddf5e006e2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/storage/sql/SqlUserBackend.java @@ -18,6 +18,9 @@ public interface SqlUserBackend extends AutoCloseable { /** Explicit opt-in: user transactions atomically commit caller-owned SQL and user values. */ default boolean supportsAtomicUserTransactions() { return false; } + /** Column identifier folding, or null when this provider cannot guarantee it. */ + default Boolean caseInsensitiveColumnNames() { return null; } + UserStorage storageType(); SqlUserStorage user(UUID uuid); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java new file mode 100644 index 0000000000..a1ca6e997b --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/misc/ConsoleMissingIdentityTest.java @@ -0,0 +1,494 @@ +package com.bencodez.advancedcore.tests.misc; + +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.concurrent.CompletionException; +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.Server; +import org.bukkit.command.ConsoleCommandSender; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.MiscUtils; +import com.bencodez.advancedcore.api.rewards.Reward; +import com.bencodez.advancedcore.tests.BaseTest; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class ConsoleMissingIdentityTest { + private AdvancedCorePlugin plugin; + private MiscUtils misc; + private Server server; + private ConsoleCommandSender console; + + @Test void freshDurableExecutionRejectsStaleCommandSnapshot() throws Exception { + var keyMethod = Reward.class.getDeclaredMethod("replaySequenceKey", String.class, String.class, String.class); + keyMethod.setAccessible(true); + String key = (String) keyMethod.invoke(null, "__advancedcore_replay_commands_", "reward/0", "console"); + var values = new HashMap(); values.put(key + "_snapshot", "v1:c2F5IG9sZA"); values.put(key, "1"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setFreshAsyncReplayEntry(true); + options.setAsyncReplayCheckpointConsumer(checkpoint -> {}); + var state = Reward.replayStateFor(options); + assertFalse(Reward.hasReplayCommandSnapshot(values, "console", state, "reward/0")); + state.mergeReplayMetadataInto(values); + assertFalse(values.containsKey(key)); assertFalse(values.containsKey(key + "_snapshot")); + state.recordReplayMetadata(key + "_snapshot", "v1:bmV3"); state.mergeReplayMetadataInto(values); + assertTrue(Reward.hasReplayCommandSnapshot(values, "console", state, "reward/0")); + } + @Test void restoredZeroProgressQueueAcceptsItsPersistedCommandSnapshot() throws Exception { + var keyMethod = Reward.class.getDeclaredMethod("replaySequenceKey", String.class, String.class, String.class); + keyMethod.setAccessible(true); + String key = (String) keyMethod.invoke(null, "__advancedcore_replay_commands_", "reward/0", "console"); + var values = new HashMap(); values.put(key + "_snapshot", "v1:c2F5IG9sZA"); + var options = new com.bencodez.advancedcore.api.rewards.RewardOptions(); + options.setRestoredAsyncReplayEntry(true); options.setAsyncReplayCheckpointConsumer(checkpoint -> {}); + var state = Reward.replayStateFor(options); + assertTrue(Reward.hasReplayCommandSnapshot(values, "console", state, "reward/0")); + state.mergeReplayMetadataInto(values); assertTrue(values.containsKey(key + "_snapshot")); + } + @BeforeEach void setup() throws Exception { + plugin = BaseTest.getInstance().plugin; + misc = MiscUtils.getInstance(); + Field field = MiscUtils.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(misc, plugin); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTask(eq(plugin), any(Runnable.class)); + doAnswer(call -> { call.getArgument(1, Runnable.class).run(); return null; }) + .when(scheduler).runTaskLater(eq(plugin), any(Runnable.class), anyLong()); + server = mock(Server.class); + console = mock(ConsoleCommandSender.class); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(false); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(false); + } + @Test void literalSingleAndListDoNotLookUpMissingIdentity() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + HashMap values = new HashMap<>(); values.put("value", "hello"); + misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join(); + misc.executeConsoleCommandsAsync(null, new ArrayList<>(List.of("say one", "say two")), values, true) + .toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say hello"); + verify(server).dispatchCommand(console, "say one"); + verify(server).dispatchCommand(console, "say two"); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void playerAndEnabledExternalOrScriptTokensFailWithoutDispatch() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync(" ", "give %player% stone", new HashMap<>()).toCompletableFuture().join()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync(null, new ArrayList<>(List.of("give {player} stone")), new HashMap<>(), true).toCompletableFuture().join()); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", "give %player% stone", new HashMap<>())); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", new ArrayList<>(List.of("give %player% stone")), new HashMap<>(), true)); + for (String alias : List.of("{@p}", "%@p%", "{@P}", "%@P%")) { + HashMap builtins = new HashMap<>(); builtins.put("@p", ""); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "give " + alias + " stone", builtins).toCompletableFuture().join()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", new ArrayList<>(List.of("give " + alias + " stone")), builtins, true).toCompletableFuture().join()); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", "give " + alias + " stone", builtins)); + assertThrows(IllegalStateException.class, () -> misc.executeConsoleCommands("", new ArrayList<>(List.of("give " + alias + " stone")), builtins, true)); + } + for (boolean aliasFirst : List.of(false, true)) { + HashMap mapped = new java.util.LinkedHashMap<>(); + if (aliasFirst) mapped.put("@p", ""); + mapped.put("target", "{@p}"); + if (!aliasFirst) mapped.put("@p", ""); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "give %target% stone", mapped).toCompletableFuture().join()); + } + for (HashMap mapped : List.of(new HashMap(), new java.util.LinkedHashMap())) { + mapped.put("targetName", "%name%"); + mapped.put("name", "%player%"); + mapped.put("player", ""); + assertEquals("give stone", com.bencodez.advancedcore.api.messages.PlaceholderUtils + .replacePlaceHolder("give %targetName% stone", mapped)); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "give %targetName% stone", mapped).toCompletableFuture().join()); + } + HashMap injected = new HashMap<>(); injected.put("target", "%player%"); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say %target%", injected).toCompletableFuture().join()); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say %some_token%", new HashMap<>()).toCompletableFuture().join()); + injected.put("target", "%some_token%"); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say %target%", injected).toCompletableFuture().join()); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", "say [Javascript=player.getName()]", new HashMap<>()).toCompletableFuture().join()); + for (String script : List.of("say [Javaſcript=player.getName()]", "say [Javascrıpt=player.getName()]")) { + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", script, new HashMap<>()).toCompletableFuture().join()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", new ArrayList<>(List.of(script)), new HashMap<>(), true).toCompletableFuture().join()); + } + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + verifyNoInteractions(server); + } + } + @Test void missingNameUsesActualPlaceholderApiTokenBoundaries() { + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + var replacer = new me.clip.placeholderapi.replacer.CharsReplacer( + me.clip.placeholderapi.replacer.Replacer.Closure.PERCENT); + List commands = List.of("say 50% complete, 100% ready", "say %literal%", "say %%foo_bar%", + "say %foo_bar%", "say %foo_bar with spaces%", "say \\%foo_bar%", "say %foo bar_baz%", + "say %foo bar_baz%foo_bar%", "say %foo_", "say %_x%", "say %foo\t_bar%", + "say [Javascript='%some_token%']", "say %foo[Javascript=x]_bar%", + "say [Javascript=x] %some_token%", "say %some_token% [Javascript=x]"); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + for (String command : commands) { + clearInvocations(server); + var called = new java.util.concurrent.atomic.AtomicBoolean(); + com.bencodez.advancedcore.api.messages.PlaceholderUtils.replaceExternalPlaceholders(command, + text -> replacer.apply(text, null, ignored -> { called.set(true); return null; }), false); + if (called.get()) { + assertThrows(CompletionException.class, + () -> misc.executeConsoleCommandsAsync("", command, new HashMap<>()).toCompletableFuture().join(), command); + verifyNoInteractions(server); + } else { + misc.executeConsoleCommandsAsync("", command, new HashMap<>()).toCompletableFuture().join(); + verify(server).dispatchCommand(console, command); + } + } + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void mappedTextHonorsEnabledAndDisabledExpansionBoundaries() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + HashMap values = new HashMap<>(); + values.put("value", "%external_token%"); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(false); + misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say %external_token%"); + when(plugin.isPlaceHolderAPIEnabled()).thenReturn(true); + assertThrows(CompletionException.class, + () -> misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join()); + values.put("value", "[Javascript=1+1]"); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + misc.executeConsoleCommandsAsync("", "say %value%", values).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say [Javascript =1+1]"); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("", + "say [Javascript=1+1]", new HashMap<>()).toCompletableFuture().join()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void scriptResultCannotCreateASecondExecutableSegment() { + var offline = mock(OfflinePlayer.class); + when(offline.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + try (MockedStatic bukkit = mockStatic(Bukkit.class); + var engines = mockConstruction(com.bencodez.advancedcore.api.javascript.JavascriptEngine.class, + (engine, context) -> { + when(engine.addPlayer(any(OfflinePlayer.class))).thenReturn(engine); + when(engine.getStringValue(anyString())).thenReturn("[Javascript=2+2]"); + })) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Known")).thenReturn(offline); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + misc.executeConsoleCommandsAsync("Known", "say [Javascript=1+1]", new HashMap<>()).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say [Javascript =2+2]"); + assertEquals(1, engines.constructed().size()); + verify(engines.constructed().get(0)).getStringValue("1+1"); + } + } + @Test void synchronousAdaptersDispatchLiteralCommandsWithoutAName() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + misc.executeConsoleCommands("", "say single", new HashMap<>()); + misc.executeConsoleCommands((String) null, new ArrayList<>(List.of("say list")), new HashMap<>(), true); + verify(server).dispatchCommand(console, "say single"); + verify(server).dispatchCommand(console, "say list"); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } + } + @Test void offlineModeMissingNameNeverSelectsAnUnrelatedPlayerForMixedRewards() throws Exception { + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(true); + when(plugin.isEnabled()).thenReturn(true); + var user = new com.bencodez.advancedcore.api.user.AdvancedCoreUser(plugin, java.util.UUID.randomUUID(), ""); + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(false); + var unrelated = mock(org.bukkit.entity.Player.class); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(handler, plugin); + var sectionHandler = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection) injections.get(1); + var section = new org.bukkit.configuration.file.YamlConfiguration(); + section.set("Console", new ArrayList<>(List.of("say console"))); + section.set("Player", new ArrayList<>(List.of("say player"))); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer("")).thenReturn(unrelated); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + assertNull(user.getPlayer()); + assertNull(user.getOfflinePlayer()); + var unnamed = spy(user); + doReturn(null).when(unnamed).getPlayerName(); + assertNull(unnamed.getPlayer()); + assertNull(unnamed.getOfflinePlayer()); + assertThrows(CompletionException.class, () -> sectionHandler + .onRewardRequestedAsync(null, user, section, new HashMap<>()).toCompletableFuture().join()); + verifyNoInteractions(server, unrelated); + bukkit.verify(() -> Bukkit.getPlayer(""), never()); + bukkit.verify(() -> Bukkit.getOfflinePlayer(""), never()); + bukkit.verify(() -> Bukkit.getPlayer((String) null), never()); + bukkit.verify(() -> Bukkit.getOfflinePlayer((String) null), never()); + var reward = new Reward("test", new org.bukkit.configuration.file.YamlConfiguration()); + var prepare = Reward.class.getDeclaredMethod("prepareRewardUser", + com.bencodez.advancedcore.api.user.AdvancedCoreUser.class, HashMap.class); + prepare.setAccessible(true); + assertNull(prepare.invoke(reward, user, new HashMap<>())); + bukkit.verify(() -> Bukkit.getPlayer(""), never()); + } + } + @Test void playerLookupPreservesValidNameAndUuidPaths() { + var id = java.util.UUID.randomUUID(); + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(true); + var user = new com.bencodez.advancedcore.api.user.AdvancedCoreUser(plugin, id, "Known"); + var player = mock(org.bukkit.entity.Player.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(false); + bukkit.when(() -> Bukkit.getPlayer("Known")).thenReturn(player); + assertSame(player, user.getPlayer()); + when(BaseTest.getInstance().options.isOnlineMode()).thenReturn(true); + bukkit.when(() -> Bukkit.getPlayer(id)).thenReturn(player); + assertSame(player, user.getPlayer()); + } + } + @Test void permissionFallbackNeverPerformsBlankNameLookup() { + var manager = com.bencodez.advancedcore.api.misc.PlayerManager.getInstance(); + manager.setPlugin(plugin); + var id = java.util.UUID.randomUUID(); + var unrelated = mock(org.bukkit.entity.Player.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getPlayer("")).thenReturn(unrelated); + assertFalse(manager.hasPermission("", "test")); + assertFalse(manager.hasServerPermission(" ", "test")); + assertFalse(manager.hasServerPermission(id, "", "test")); + bukkit.verify(() -> Bukkit.getPlayer(""), never()); + bukkit.verify(() -> Bukkit.getPlayer(" "), never()); + verifyNoInteractions(unrelated); + } + } + @Test void validNamesStillExpandForOfflineAndOnlinePlayers() { + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + OfflinePlayer offline = mock(OfflinePlayer.class); + OfflinePlayer online = mock(OfflinePlayer.class); + org.bukkit.entity.Player live = mock(org.bukkit.entity.Player.class); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(offline.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(live.getUniqueId()).thenReturn(java.util.UUID.randomUUID()); + when(BaseTest.getInstance().options.isJavascriptEngineEnabled()).thenReturn(true); + when(online.isOnline()).thenReturn(true); + when(online.getPlayer()).thenReturn(live); + bukkit.when(() -> Bukkit.getOfflinePlayer("Offline")).thenReturn(offline); + bukkit.when(() -> Bukkit.getOfflinePlayer("Online")).thenReturn(online); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + misc.executeConsoleCommandsAsync("Offline", new ArrayList<>(List.of("say %player%")), new HashMap<>(), false).toCompletableFuture().join(); + HashMap values = new HashMap<>(); values.put("player", "Online"); + misc.executeConsoleCommandsAsync("Online", "say %player%", values).toCompletableFuture().join(); + verify(server).dispatchCommand(console, "say Offline"); + verify(server).dispatchCommand(console, "say Online"); + verify(offline, atLeastOnce()).isOnline(); + verify(online).getPlayer(); + } + } + @Test void capturedSnapshotSkipsCompletedPrefixWithMissingName() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + HashMap values = new HashMap<>(); + List dispatched = new ArrayList<>(); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Known")).thenReturn(null); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + doAnswer(call -> { String command = call.getArgument(1); dispatched.add(command); + if (command.equals("second") && dispatched.size() == 2) throw new IllegalStateException("temporary"); + return true; }).when(server).dispatchCommand(eq(console), anyString()); + assertThrows(CompletionException.class, () -> misc.executeConsoleCommandsAsync("Known", new ArrayList<>(List.of("first", "second")), values, false, null, state, "reward/0").toCompletableFuture().join()); + misc.executeConsoleCommandsAsync("", new ArrayList<>(List.of("changed %player%")), values, false, null, state, "reward/0").toCompletableFuture().join(); + assertEquals(List.of("first", "second", "second"), dispatched); + bukkit.verify(() -> Bukkit.getOfflinePlayer(""), never()); + } + } + @Test void configuredCommandFormsDispatchLiteralsAndRejectPlayerTargets() { + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.register(handler, plugin); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(user.getPlayerName()).thenReturn(""); + when(user.preformCommandAsync(any(), any())).thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var reward = mock(Reward.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + var number = new org.bukkit.configuration.file.YamlConfiguration(); + number.set("NumberCommand.Min", 1); number.set("NumberCommand.Max", 2); + number.set("NumberCommand.Command", "say number"); + var single = new org.bukkit.configuration.file.YamlConfiguration(); single.set("Command", "say single"); + var list = new org.bukkit.configuration.file.YamlConfiguration(); list.set("Commands", new ArrayList<>(List.of("say list"))); + var mixed = new org.bukkit.configuration.file.YamlConfiguration(); mixed.set("Commands.Console", new ArrayList<>(List.of("say mixed"))); + var random = new org.bukkit.configuration.file.YamlConfiguration(); random.set("RandomCommand", new ArrayList<>(List.of("say random"))); + var configs = List.of(number, single, list, mixed, random); + for (int i = 0; i < injections.size(); i++) { + injections.get(i).onRewardRequestAsync(reward, user, configs.get(i), new HashMap<>()).toCompletableFuture().join(); + } + for (String command : List.of("say number", "say single", "say list", "say mixed", "say random")) { + verify(server).dispatchCommand(console, command); + } + number.set("NumberCommand.Command", "give %player% stone"); + single.set("Command", "give %player% stone"); + list.set("Commands", new ArrayList<>(List.of("give %player% stone"))); + mixed.set("Commands.Console", new ArrayList<>(List.of("give %player% stone"))); + random.set("RandomCommand", new ArrayList<>(List.of("give %player% stone"))); + for (int i = 0; i < injections.size(); i++) { + int index = i; + assertThrows(CompletionException.class, () -> injections.get(index) + .onRewardRequestAsync(reward, user, configs.get(index), new HashMap<>()).toCompletableFuture().join()); + } + number.set("NumberCommand.Command", "give {@p} stone"); + single.set("Command", "give {@p} stone"); + list.set("Commands", new ArrayList<>(List.of("give {@p} stone"))); + mixed.set("Commands.Console", new ArrayList<>(List.of("give {@p} stone"))); + random.set("RandomCommand", new ArrayList<>(List.of("give {@p} stone"))); + for (int i = 0; i < injections.size(); i++) { + int index = i; + HashMap builtins = new HashMap<>(); builtins.put("@p", ""); + assertThrows(CompletionException.class, () -> injections.get(index) + .onRewardRequestAsync(reward, user, configs.get(index), builtins).toCompletableFuture().join()); + } + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + verify(server, never()).dispatchCommand(eq(console), contains("%player%")); + } + } + @Test void configuredListReplayResumesOnlyFailedCommandWithMissingIdentity() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(handler, plugin); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(user.getPlayerName()).thenReturn("Known", ""); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + config.set("Commands", new ArrayList<>(List.of("first", "second"))); + var placeholders = new HashMap(); + var reward = mock(Reward.class); + List dispatches = new ArrayList<>(); + Field stateField = Reward.class.getDeclaredField("ACTIVE_REPLAY_STATE"); stateField.setAccessible(true); + Field keyField = Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") ThreadLocal stateLocal = (ThreadLocal) stateField.get(null); + @SuppressWarnings("unchecked") ThreadLocal keyLocal = (ThreadLocal) keyField.get(null); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(() -> Bukkit.getOfflinePlayer("Known")).thenReturn(null); + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + doAnswer(call -> { String command = call.getArgument(1); dispatches.add(command); + if (command.equals("second") && dispatches.size() == 2) throw new IllegalStateException("temporary"); + return true; }).when(server).dispatchCommand(eq(console), anyString()); + stateLocal.set(state); keyLocal.set("reward/0"); + assertThrows(CompletionException.class, () -> injections.get(0) + .onRewardRequestAsync(reward, user, config, placeholders).toCompletableFuture().join()); + config.set("Commands", new ArrayList<>(List.of("changed %player%"))); + injections.get(0).onRewardRequestAsync(reward, user, config, placeholders).toCompletableFuture().join(); + assertEquals(List.of("first", "second", "second"), dispatches); + bukkit.verify(() -> Bukkit.getOfflinePlayer(""), never()); + } finally { stateLocal.remove(); keyLocal.remove(); } + } + @Test void configuredSingleRandomAndLegacySectionUseSavedCommandsWithoutIdentity() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.register(handler, plugin); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(user.getPlayerName()).thenReturn(""); + when(user.preformCommandAsync(any(), any(), isNull(), any(), anyString())) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + var reward = mock(Reward.class); + Field stateField = Reward.class.getDeclaredField("ACTIVE_REPLAY_STATE"); stateField.setAccessible(true); + Field keyField = Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") ThreadLocal stateLocal = (ThreadLocal) stateField.get(null); + @SuppressWarnings("unchecked") ThreadLocal keyLocal = (ThreadLocal) keyField.get(null); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getConsoleSender).thenReturn(console); + for (int index : List.of(1, 3, 4)) { + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + HashMap values = new HashMap<>(); + assertThrows(CompletionException.class, () -> Reward.replayCommandSequence(plugin, values, "console", + List.of("saved"), List.of("saved"), state, "reward/0", + (command, position) -> java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("temporary"))) + .toCompletableFuture().join()); + var config = new org.bukkit.configuration.file.YamlConfiguration(); + if (index == 1) config.set("Command", "give %player% stone"); + if (index == 3) config.set("Commands.Console", new ArrayList<>(List.of("give %player% stone"))); + if (index == 4) config.set("RandomCommand", new ArrayList<>(List.of("give %player% stone"))); + stateLocal.set(state); keyLocal.set("reward/0"); + injections.get(index).onRewardRequestAsync(reward, user, config, values).toCompletableFuture().join(); + stateLocal.remove(); keyLocal.remove(); + } + verify(server, times(3)).dispatchCommand(console, "saved"); + bukkit.verify(() -> Bukkit.getOfflinePlayer(anyString()), never()); + } finally { stateLocal.remove(); keyLocal.remove(); } + } + @Test void mixedSectionCarriesNullGuardAndReplayAcrossAvailabilityThread() throws Exception { + var ctor = Reward.ReplayState.class.getDeclaredConstructor(Map.class, Map.class, boolean.class); + ctor.setAccessible(true); + Reward.ReplayState state = ctor.newInstance(new HashMap<>(), new HashMap<>(), false); + HashMap values = new HashMap<>(); + assertThrows(CompletionException.class, () -> Reward.replayCommandSequence(plugin, values, "console", + List.of("first", "second"), List.of("first", "second"), state, "reward/0", + (command, index) -> index == 1 + ? java.util.concurrent.CompletableFuture.failedFuture(new IllegalStateException("temporary")) + : java.util.concurrent.CompletableFuture.completedFuture(null)).toCompletableFuture().join()); + var handler = mock(com.bencodez.advancedcore.api.rewards.RewardHandler.class); + var injections = new java.util.concurrent.CopyOnWriteArrayList(); + when(handler.getInjectedRewards()).thenReturn(injections); + com.bencodez.advancedcore.api.rewards.builtin.RewardCommands.registerCommands(handler, plugin); + var sectionHandler = (com.bencodez.advancedcore.api.rewards.injected.RewardInjectConfigurationSection) injections.get(1); + var section = new org.bukkit.configuration.file.YamlConfiguration(); + section.set("Player", new ArrayList<>(List.of("player-command"))); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var available = new java.util.concurrent.CompletableFuture(); + when(user.validatePlayerCommandAvailabilityAsync()).thenReturn(available); + when(user.getPlayerName()).thenReturn(""); + when(user.preformCommandAsync(any(), any(), isNull(), same(state), eq("reward/0"))) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + MiscUtils fake = mock(MiscUtils.class); + when(fake.executeConsoleCommandsAsync(eq(""), any(ArrayList.class), same(values), eq(true), isNull(), same(state), eq("reward/0"))) + .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(null)); + Field instance = MiscUtils.class.getDeclaredField("instance"); instance.setAccessible(true); + Object old = instance.get(null); + Field stateField = Reward.class.getDeclaredField("ACTIVE_REPLAY_STATE"); stateField.setAccessible(true); + Field keyField = Reward.class.getDeclaredField("ACTIVE_REPLAY_KEY"); keyField.setAccessible(true); + @SuppressWarnings("unchecked") ThreadLocal stateLocal = (ThreadLocal) stateField.get(null); + @SuppressWarnings("unchecked") ThreadLocal keyLocal = (ThreadLocal) keyField.get(null); + try { + instance.set(null, fake); stateLocal.set(state); keyLocal.set("reward/0"); + var result = sectionHandler.onRewardRequestedAsync(null, user, section, values); + stateLocal.remove(); keyLocal.remove(); + java.util.concurrent.CompletableFuture.runAsync(() -> available.complete(null)).get(5, java.util.concurrent.TimeUnit.SECONDS); + result.toCompletableFuture().get(5, java.util.concurrent.TimeUnit.SECONDS); + verify(fake).executeConsoleCommandsAsync(eq(""), any(ArrayList.class), same(values), eq(true), isNull(), same(state), eq("reward/0")); + verify(user).preformCommandAsync(any(), same(values), isNull(), same(state), eq("reward/0")); + } finally { instance.set(null, old); stateLocal.remove(); keyLocal.remove(); } + } +} 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 c8e6f65934..1dc2f8da2a 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 @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -146,6 +147,230 @@ void tearDown() { AdvancedCorePlugin.setInstance(null); } + @Test + void legacyFailureObserverRunsOnceForEligibilityBranchAndDoesNotMaskFailure() { + data.set("GameModes", List.of("SURVIVAL")); + Reward target = org.mockito.Mockito.spy(reward); + RuntimeException original = new RuntimeException("original"); + RewardOptions options = new RewardOptions(); + AtomicInteger calls = new AtomicInteger(); + AtomicReference seen = new AtomicReference<>(); + options.setAsyncFailureObserver(failure -> { calls.incrementAndGet(); seen.set(failure); throw new IllegalStateException("observer"); }); + doReturn(CompletableFuture.failedFuture(original)).when(target).giveRewardAsync(eq(user), any(RewardOptions.class)); + target.giveReward(user, options); + assertEquals(1, calls.get()); + assertSame(original, seen.get()); + verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); + } + + @Test + void eventObserverOverridesCallerForOnlyThatLegacyDispatch() { + data.set("GameModes", List.of("SURVIVAL")); + var config = mock(AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(config); + when(config.isProcessRewards()).thenReturn(true); + var manager = mock(org.bukkit.plugin.PluginManager.class); + AtomicInteger originalCalls = new AtomicInteger(), listenerCalls = new AtomicInteger(); + RewardOptions options = new RewardOptions().setCheckTimed(false).addPlaceholder("date", "fixed"); + options.setAsyncFailureObserver(failure -> originalCalls.incrementAndGet()); + doAnswer(invocation -> { + var event = invocation.getArgument(0, com.bencodez.advancedcore.listeners.PlayerRewardEvent.class); + event.getRewardOptions().setAsyncFailureObserver(failure -> listenerCalls.incrementAndGet()); + enabled.set(false); + return null; + }).when(manager).callEvent(any(com.bencodez.advancedcore.listeners.PlayerRewardEvent.class)); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + reward.giveReward(user, options); + } + assertEquals(0, originalCalls.get()); + assertEquals(1, listenerCalls.get()); + assertNotNull(options.getAsyncFailureObserver(), "caller options remain unchanged"); + } + + @Test + void eventObserverIsCapturedBeforeTheDeferredDispatchContinues() { + data.set("GameModes", List.of("SURVIVAL")); + var config = mock(AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(config); + when(config.isProcessRewards()).thenReturn(true); + var manager = mock(org.bukkit.plugin.PluginManager.class); + List queued = new ArrayList<>(); + doAnswer(invocation -> { queued.add(invocation.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicInteger callerCalls = new AtomicInteger(), eventCalls = new AtomicInteger(), lateCalls = new AtomicInteger(); + AtomicReference eventOptions = new AtomicReference<>(); + RewardOptions options = new RewardOptions().setCheckTimed(false).addPlaceholder("date", "fixed"); + options.setAsyncFailureObserver(failure -> callerCalls.incrementAndGet()); + doAnswer(invocation -> { + var event = invocation.getArgument(0, com.bencodez.advancedcore.listeners.PlayerRewardEvent.class); + eventOptions.set(event.getRewardOptions()); + event.getRewardOptions().setAsyncFailureObserver(failure -> eventCalls.incrementAndGet()); + return null; + }).when(manager).callEvent(any(com.bencodez.advancedcore.listeners.PlayerRewardEvent.class)); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + reward.giveReward(user, options); + assertEquals(1, queued.size()); + options.setAsyncFailureObserver(failure -> lateCalls.incrementAndGet()); + eventOptions.get().setAsyncFailureObserver(failure -> lateCalls.incrementAndGet()); + enabled.set(false); + queued.get(0).run(); + } + assertEquals(0, callerCalls.get()); + assertEquals(1, eventCalls.get()); + assertEquals(0, lateCalls.get()); + } + + @Test + void explicitChildUsingRetainedEventOptionsCannotReplacePendingParentObserver() throws Exception { + data.set("GameModes", List.of("SURVIVAL")); + var config = mock(AdvancedCoreConfigOptions.class); + when(plugin.getOptions()).thenReturn(config); + when(config.isProcessRewards()).thenReturn(true); + Reward child = new Reward("Child", data); + var pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(child, plugin); + var manager = mock(org.bukkit.plugin.PluginManager.class); + List queued = new ArrayList<>(); + doAnswer(invocation -> { queued.add(invocation.getArgument(1)); return null; }) + .when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicInteger parentCalls = new AtomicInteger(), childCalls = new AtomicInteger(); + AtomicReference retained = new AtomicReference<>(); + RewardOptions options = new RewardOptions().setCheckTimed(false).addPlaceholder("date", "fixed"); + doAnswer(invocation -> { + var event = invocation.getArgument(0, com.bencodez.advancedcore.listeners.PlayerRewardEvent.class); + if (event.getReward() == reward) { + retained.set(event.getRewardOptions()); + event.getRewardOptions().setAsyncFailureObserver(failure -> parentCalls.incrementAndGet()); + } else if (event.getReward() == child) { + event.getRewardOptions().setAsyncFailureObserver(failure -> childCalls.incrementAndGet()); + } + return null; + }).when(manager).callEvent(any(com.bencodez.advancedcore.listeners.PlayerRewardEvent.class)); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(manager); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + reward.giveReward(user, options); + assertEquals(1, queued.size()); + CompletionStage childResult = child.giveRewardAsync(user, retained.get()); + assertEquals(2, queued.size()); + enabled.set(false); + queued.get(0).run(); + queued.get(1).run(); + assertThrows(CompletionException.class, () -> childResult.toCompletableFuture().join()); + } + assertEquals(1, parentCalls.get()); + assertEquals(0, childCalls.get()); + } + + @Test + void legacyInjectionFailureObserverDoesNotChangeExplicitAsyncFailure() { + RewardInject async = mock(RewardInject.class); + when(async.supportsAsyncRequest()).thenReturn(true); + handler.getInjectedRewards().add(async); + Reward target = org.mockito.Mockito.spy(reward); + RuntimeException original = new RuntimeException("original"); + RewardOptions options = new RewardOptions(); + AtomicInteger calls = new AtomicInteger(); + options.setAsyncFailureObserver(failure -> { assertSame(original, failure); calls.incrementAndGet(); }); + doReturn(CompletableFuture.failedFuture(original)).when(target).giveRewardUserAsync(eq(user), any(HashMap.class), eq(options)); + assertThrows(CompletionException.class, () -> target.giveRewardUserAsync(user, new HashMap<>(), options).toCompletableFuture().join()); + assertEquals(0, calls.get()); + target.giveRewardUser(user, new HashMap<>(), options); + assertEquals(1, calls.get()); + verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); + } + + @Test + void legacyNestedAsyncFailureNotifiesOnlyAtParentTerminalBoundary() { + RewardInject async = mock(RewardInject.class); + when(async.supportsAsyncRequest()).thenReturn(true); + handler.getInjectedRewards().add(async); + Reward target = org.mockito.Mockito.spy(reward); + Reward child = mock(Reward.class); + RuntimeException original = new RuntimeException("nested"); + RewardOptions options = new RewardOptions(); AtomicInteger calls = new AtomicInteger(); + options.setAsyncFailureObserver(failure -> { assertSame(original, failure); calls.incrementAndGet(); }); + when(child.giveRewardAsync(user, options)).thenReturn(CompletableFuture.failedFuture(original)); + doAnswer(invocation -> child.giveRewardAsync(user, options)) + .when(target).giveRewardUserAsync(eq(user), any(HashMap.class), eq(options)); + target.giveRewardUser(user, new HashMap<>(), options); + verify(child).giveRewardAsync(user, options); + assertEquals(1, calls.get()); + verify(plugin.getLogger()).log(eq(java.util.logging.Level.WARNING), any(String.class), eq(original)); + } + + @Test + void pendingLegacyFailuresKeepTheObserverCapturedAtDispatch() { + RewardInject async = mock(RewardInject.class); + when(async.supportsAsyncRequest()).thenReturn(true); + handler.getInjectedRewards().add(async); + Reward target = org.mockito.Mockito.spy(reward); + RewardOptions options = new RewardOptions(); + AtomicInteger originalCalls = new AtomicInteger(), replacementCalls = new AtomicInteger(); + options.setAsyncFailureObserver(failure -> originalCalls.incrementAndGet()); + CompletableFuture pending = new CompletableFuture<>(); + doReturn(pending).when(target).giveRewardUserAsync(eq(user), any(HashMap.class), eq(options)); + target.giveRewardUser(user, new HashMap<>(), options); + options.setAsyncFailureObserver(failure -> replacementCalls.incrementAndGet()); + pending.completeExceptionally(new IllegalStateException("failure")); + assertEquals(1, originalCalls.get()); + assertEquals(0, replacementCalls.get()); + } + + @Test + void failureObserverSurvivesDispatchAndNestedOptionCopies() throws Exception { + RewardOptions options = new RewardOptions(); + java.util.function.Consumer observer = failure -> { }; + options.setAsyncFailureObserver(observer); + var dispatch = RewardOptions.class.getDeclaredMethod("copyForDispatch"); dispatch.setAccessible(true); + var nested = RewardOptions.class.getDeclaredMethod("copyForNestedDispatch", String.class); nested.setAccessible(true); + RewardOptions dispatchCopy = (RewardOptions) dispatch.invoke(options); + RewardOptions nestedCopy = (RewardOptions) nested.invoke(options, "parent/child"); + assertSame(observer, dispatchCopy.getAsyncFailureObserver()); + assertSame(observer, nestedCopy.getAsyncFailureObserver()); + assertNull(new RewardOptions().getAsyncFailureObserver()); + var capture = RewardOptions.class.getDeclaredMethod("captureLegacyFailureObserver"); capture.setAccessible(true); + var holder = RewardOptions.class.getDeclaredMethod("legacyFailureObserverCapture"); holder.setAccessible(true); + var update = RewardOptions.class.getDeclaredMethod("updateLegacyFailureObserverCapture"); update.setAccessible(true); + RewardOptions tracked = (RewardOptions) capture.invoke(options); + RewardOptions child = (RewardOptions) nested.invoke(tracked, "parent/child"); + child.setAsyncFailureObserver(failure -> { }); + update.invoke(child); + assertSame(observer, ((AtomicReference) holder.invoke(tracked)).get(), + "nested options must not replace the parent's terminal observer"); + } + + @Test + void legacyListFanoutNotifiesOncePerConcreteFailure() throws Exception { + data.set("GameModes", List.of("SURVIVAL")); + Reward first = org.mockito.Mockito.spy(reward); + Reward second = org.mockito.Mockito.spy(new Reward("Second", data)); + var pluginField = Reward.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(second, plugin); + var firstFailure = new IllegalStateException("first"); + var secondFailure = new IllegalStateException("second"); + doReturn(CompletableFuture.failedFuture(firstFailure)).when(first).giveRewardAsync(eq(user), any(RewardOptions.class)); + doReturn(CompletableFuture.failedFuture(secondFailure)).when(second).giveRewardAsync(eq(user), any(RewardOptions.class)); + RewardHandler lookup = mock(RewardHandler.class); + when(lookup.getReward("First")).thenReturn(first); + when(lookup.getReward("Second")).thenReturn(second); + var executor = new com.bencodez.advancedcore.api.rewards.RewardExecutor(lookup, plugin); + var config = new YamlConfiguration(); config.set("Rewards", new ArrayList<>(List.of("First", "Second"))); + RewardOptions options = new RewardOptions(); List failures = new ArrayList<>(); + options.setAsyncFailureObserver(failures::add); + try (var bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(false); + executor.giveReward(user, config, "Rewards", options); + } + assertEquals(List.of(firstFailure, secondFailure), failures); + } + @Test void reusedTopLevelOptionsDoNotShareCompletedReplayState() { AtomicInteger invocations = new AtomicInteger(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java index 6c3407d735..75a03480c0 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqliteUserTransactionTest.java @@ -72,13 +72,13 @@ class SqliteUserTransactionTest { var owner = mock(UserCacheOwner.class); var runtime = new SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); try { var initial = Map.of("Points", new DataValueInt(0)); - var receipt = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + var receipt = manager.submitSharedReplayTransaction(uuid, TYPE, "event-one", initial, scope -> { if (receipt(scope.connection(), "event-one")) return false; var row = scope.readRow(); int previous = value(row, "Points"); scope.writeValues(Map.of("Points", new DataValueInt(previous + 1))); insert(scope.connection(), "event-one"); return true; }).toCompletableFuture(); assertTrue(committed.await(5, TimeUnit.SECONDS)); - var next = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + var next = manager.submitSharedReplayTransaction(uuid, TYPE, "event-two", initial, scope -> { if (receipt(scope.connection(), "event-two")) return false; var row = scope.readRow(); int previous = value(row, "Points"); scope.writeValues(Map.of("Points", new DataValueInt(previous + 1))); insert(scope.connection(), "event-two"); return true; @@ -96,10 +96,10 @@ class SqliteUserTransactionTest { var owner = mock(UserCacheOwner.class); var runtime = new SharedUserDataRuntime(backend, owner); manager.bindSharedRuntime(runtime); try { var initial = Map.of("Points", new DataValueInt(0)); - var rejected = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + var rejected = manager.submitSharedReplayTransaction(uuid, TYPE, "fixture-operation", initial, scope -> { throw new SqlUserStorage.ReplayPreconditionException("invalid intent"); }).toCompletableFuture(); - var next = manager.submitSharedReplayTransaction(uuid, TYPE, initial, scope -> { + var next = manager.submitSharedReplayTransaction(uuid, TYPE, "fixture-operation", initial, scope -> { scope.writeValues(Map.of("Points", new DataValueInt(2))); return true; }).toCompletableFuture(); var failure = assertThrows(java.util.concurrent.ExecutionException.class, () -> rejected.get(5, TimeUnit.SECONDS)); @@ -116,7 +116,7 @@ class SqliteUserTransactionTest { var entered = new CountDownLatch(1); var continueTransaction = new CountDownLatch(1); var callbackEntered = new CountDownLatch(1); var continueCallback = new CountDownLatch(1); try { - var operation = manager.submitSharedReplayTransaction(uuid, TYPE, + var operation = manager.submitSharedReplayTransaction(uuid, TYPE, "fixture-operation", Map.of("Points", new DataValueInt(0)), scope -> { entered.countDown(); try { assertTrue(continueTransaction.await(5, TimeUnit.SECONDS)); } @@ -152,7 +152,7 @@ class SqliteUserTransactionTest { manager.settleSharedReplayLease(uuid, identity, true, false); } assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> manager.acquireSharedReplayLease(uuid, "new-award")); - assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> manager.submitSharedReplayTransaction(uuid, TYPE, + assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> manager.submitSharedReplayTransaction(uuid, TYPE, "fixture-operation", Map.of("Points", new DataValueInt(0)), scope -> null)); manager.acquireSharedReplayCheckpointLease(uuid, "award-0"); var cleanup = assertDoesNotThrow(() -> manager.submitSharedReplayCheckpoint(uuid, TYPE, "award-0", 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 c74197fe9c..349f6efc30 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 @@ -426,6 +426,44 @@ void deferredReplayRetainsOccurrenceAndCheckpoint() { assertTrue(persisted.get(0).contains("Server%pair%server-a")); } + @Test + void freshOfflineQueueDiscardsCallerReplayMetadataAndMarksProvenance() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { persisted.clear(); persisted.addAll(invocation.getArgument(1)); return null; }) + .when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); when(reward.getRewardName()).thenReturn("VoteReward"); + var raw = new HashMap(); raw.put("__advancedcore_replay_commands_stale_snapshot", "v1:c3RhbGU"); + raw.put("__AdvancedCoreReplayProvenance", "checkpoint"); raw.put("ordinary", "kept"); + user.addOfflineRewards(reward, raw); + assertEquals(1, persisted.size()); assertFalse(persisted.get(0).contains("__advancedcore_replay_commands_stale_snapshot")); + assertTrue(persisted.get(0).contains("__AdvancedCoreReplayProvenance%pair%trusted")); + assertTrue(persisted.get(0).contains("ordinary%pair%kept")); + } + + @Test + void restoredZeroProgressReplayRetainsProvenanceWhenDeferredAgain() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { persisted.clear(); persisted.addAll(invocation.getArgument(1)); return null; }) + .when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); when(reward.getRewardName()).thenReturn("VoteReward"); + String key = "__advancedcore_replay_selection_c2VsZWN0aW9u"; + RewardOptions restored = new RewardOptions().addPlaceholder(key, "Y2hvc2Vu"); + restored.setRestoredAsyncReplayEntry(true); + user.addOfflineRewards(reward, restored.getPlaceholders(), restored); + assertTrue(persisted.get(0).contains("__AdvancedCoreReplayProvenance%pair%trusted")); + AdvancedCoreUser restarted = new AdvancedCoreUser(plugin, UUID.randomUUID(), "Test"); + restarted.setData(data); + when(rewardHandler.givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), any(RewardOptions.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + ArgumentCaptor resumed = ArgumentCaptor.forClass(RewardOptions.class); + restarted.checkOfflineRewards(); + verify(rewardHandler).givePersistedQueueRewardAsync(eq(restarted), any(PersistedQueueReference.class), resumed.capture()); + assertTrue(resumed.getValue().isRestoredAsyncReplayEntry()); + assertEquals("Y2hvc2Vu", resumed.getValue().getPlaceholders().get(key)); + } + @Test void deferredReplayDoesNotExportStaleSelectionFromSeparateCallerPlaceholders() { ArrayList persisted = new ArrayList<>(); @@ -441,6 +479,25 @@ void deferredReplayDoesNotExportStaleSelectionFromSeparateCallerPlaceholders() { assertTrue(persisted.get(0).contains("Server%pair%server-a")); } + @Test + void deferredReplayPreservesSelectionRecordedByCurrentState() { + ArrayList persisted = new ArrayList<>(); + when(data.getStringList("offlineRewardsPath", UserDataFetchMode.DEFAULT)).thenAnswer(ignored -> new ArrayList<>(persisted)); + org.mockito.Mockito.doAnswer(invocation -> { persisted.clear(); persisted.addAll(invocation.getArgument(1)); return null; }) + .when(data).setStringList(eq("offlineRewardsPath"), any()); + Reward reward = mock(Reward.class); when(reward.getRewardName()).thenReturn("VoteReward"); + RewardOptions options = new RewardOptions(); + Reward.ReplayState state = Reward.replayStateFor(options); + String key = "__advancedcore_replay_selection_" + java.util.Base64.getUrlEncoder().withoutPadding() + .encodeToString("root/0".getBytes(java.nio.charset.StandardCharsets.UTF_8)); + state.recordReplayMetadata(key, "Y2hvc2Vu"); + options.setAsyncReplayState(state); + user.addOfflineRewards(reward, new HashMap<>(), options); + assertEquals(1, persisted.size()); + assertTrue(persisted.get(0).contains(key)); + assertTrue(persisted.get(0).contains("Y2hvc2Vu")); + } + @Test void deferredReplayAtomicallyReplacesItsExistingOccurrence() { String occurrence = UUID.randomUUID().toString(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java index 942f8fffe8..6b504b111d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AtomicIntegerMutationTest.java @@ -15,6 +15,177 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; class AtomicIntegerMutationTest { + @Test void foldedSharedProviderResolvesUniqueAliasWithoutCreatingZero() { + var manager = mock(UserDataManager.class); + when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.configureSharedStorage(v -> {}, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + assertTrue(cache.seedMissingIntegerFromCompleteSnapshot("points")); + assertEquals(4, cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).previous().get("points")); + assertEquals(5, cache.getIfPresent("Points").getInt()); + assertNull(cache.getIfPresent("points")); + } + @Test void retainedFoldedAliasesStayConsistentAcrossSharedMutations() { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); when(mysql.caseInsensitiveColumnNames()).thenReturn(true); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("points", 4), false); + cache.configureSharedStorage(v -> {}, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + assertTrue(cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).applied()); + cache.processChangesForSharedRuntime(); + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), IntegerMutation.ADD, null).applied()); + cache.processChangesForSharedRuntime(); + cache.updateCompleteCachePreservingPending(cache.snapshot()); + assertTrue(cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).applied()); + assertEquals(7, cache.getIfPresent("Points").getInt()); + assertEquals(7, cache.getIfPresent("points").getInt()); + } + @Test void olderFoldedAliasFlushCannotOverwriteNewerMutation() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + var mysql = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); when(mysql.caseInsensitiveColumnNames()).thenReturn(true); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("points", 4), false); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var worker = Executors.newSingleThreadExecutor(); + cache.configureSharedStorage(v -> { entered.countDown(); await(release); }, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + try { + assertTrue(cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).applied()); + var flush = worker.submit(cache::processChangesForSharedRuntime); assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + assertEquals(5, cache.getIfPresent("Points").getInt()); assertEquals(5, cache.getIfPresent("points").getInt()); + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), IntegerMutation.ADD, null).applied()); + release.countDown(); flush.get(5, TimeUnit.SECONDS); + assertEquals(6, cache.getIfPresent("Points").getInt()); assertEquals(6, cache.getIfPresent("points").getInt()); + assertTrue(cache.hasChangesToProcess()); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void caseSensitiveSharedProviderKeepsBothKeysDistinct() { + var manager = mock(UserDataManager.class); + when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.configureSharedStorage(v -> {}, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, false); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(4), "points", new DataValueInt(9)))); + assertTrue(cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).applied()); + assertEquals(4, cache.getIfPresent("Points").getInt()); + assertEquals(10, cache.getIfPresent("points").getInt()); + } + @Test void foldedQueuedMutationSurvivesDifferentlyCasedDirectSnapshot() { + var manager = mock(UserDataManager.class); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(manager.getPlugin()).thenReturn(plugin); when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + var written = new AtomicReference>(); + cache.configureSharedStorage(written::set, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), IntegerMutation.ADD, null).applied()); + cache.updateCache(new HashMap<>(Map.of("points", new DataValueInt(4)))); + assertEquals(5, cache.getIfPresent("points").getInt()); + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), IntegerMutation.ADD, null).applied()); + assertEquals(6, cache.getIfPresent("points").getInt()); + cache.processChangesForSharedRuntime(); + assertEquals(1, written.get().size()); + assertEquals(6, written.get().values().iterator().next().getInt()); + } + @Test void foldedQueuedSetterAndArithmeticFlushOnePhysicalColumn() { + var manager = mock(UserDataManager.class); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(manager.getPlugin()).thenReturn(plugin); when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); var written = new AtomicReference>(); + cache.configureSharedStorage(written::set, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + assertTrue(cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).applied()); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("points", 6), true); + cache.processChangesForSharedRuntime(); + assertEquals(1, written.get().size()); assertEquals(6, written.get().values().iterator().next().getInt()); + } + @Test void foldedMixedBatchUsesLastAcceptedValueDuringInflightSnapshot() throws Exception { + var manager = mock(UserDataManager.class); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(manager.getPlugin()).thenReturn(plugin); when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var writes = new java.util.concurrent.CopyOnWriteArrayList>(); + var worker = Executors.newSingleThreadExecutor(); + cache.configureSharedStorage(v -> { writes.add(new HashMap<>(v)); if (writes.size() == 1) { entered.countDown(); await(release); } }, + Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + try { + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), IntegerMutation.ADD, null).applied()); + cache.addChange(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("points", 6), true); + var first = worker.submit(cache::processChangesForSharedRuntime); assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + assertEquals(6, cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null).previous().get("points")); + release.countDown(); first.get(5, TimeUnit.SECONDS); cache.processChangesForSharedRuntime(); + assertEquals(1, writes.get(1).size()); assertEquals(7, writes.get(1).values().iterator().next().getInt()); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void foldedStagedSetterSurvivesCheckpointSnapshotReplacement() throws Exception { + var manager = mock(UserDataManager.class); var plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + when(manager.getPlugin()).thenReturn(plugin); when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(any(UUID.class), eq(false))).thenReturn(user); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); var written = new AtomicReference>(); + cache.configureSharedStorage(written::set, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var worker = Executors.newSingleThreadExecutor(); + try { + var checkpoint = worker.submit(() -> cache.flushChangesAndRun(() -> { + entered.countDown(); await(release); cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertTrue(cache.tryAddChangeBeforeDeferredSharedFlush(new com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt("points", 6))); + release.countDown(); checkpoint.get(5, TimeUnit.SECONDS); + assertTrue(cache.hasChangesToProcess()); cache.processChangesForSharedRuntime(); + assertEquals(1, written.get().size()); assertEquals(6, written.get().values().iterator().next().getInt()); + } finally { release.countDown(); worker.shutdownNow(); } + } + @Test void unknownSharedProviderStillRefusesAmbiguousAlias() { + var manager = mock(UserDataManager.class); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.configureSharedStorage(v -> {}, Runnable::run, Runnable::run, + com.bencodez.advancedcore.api.user.UserStorage.MYSQL); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(4)))); + assertNull(cache.tryMutateIntegers(Map.of("points", 1), IntegerMutation.ADD, null)); + assertFalse(cache.seedMissingIntegerFromCompleteSnapshot("points")); + } + @Test void appliedLegacyMutationSurvivesNotificationFailure() { + var manager = mock(UserDataManager.class); + var plugin = mock(AdvancedCorePlugin.class); + when(manager.getPlugin()).thenReturn(plugin); + when(plugin.getStorageType()).thenReturn(com.bencodez.advancedcore.api.user.UserStorage.SQLITE); + when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); + var error = new IllegalStateException("synthetic notification failure"); + var result = cache.tryMutateLegacyIntegers(Map.of("a", 1), IntegerMutation.ADD, keys -> { throw error; }); + assertTrue(result.applied()); + assertEquals(2, cache.getIfPresent("a").getInt()); + assertTrue(cache.hasChangesToProcess()); + verify(manager).recordSharedStorageFailure(error); + } @Test void legacyBatchProviderGuardDoesNotAffectAnotherUser() { var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); @@ -311,7 +482,7 @@ class AtomicIntegerMutationTest { @Test void sharedAwardDoesNotReportFailureAfterNotificationDispatchThrows() { var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); - doThrow(new IllegalStateException("notification failed")).when(manager).dispatchSharedUserDataNotification(any(Runnable.class)); + doThrow(new IllegalStateException("notification failed")).when(manager).dispatchSharedMutationNotification(any(Runnable.class)); var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(values -> {}, Runnable::run); cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("a", new DataValueInt(1)))); assertTrue(cache.tryMutateIntegers(Map.of("a", 1), IntegerMutation.ADD, keys -> {}).applied()); @@ -449,6 +620,33 @@ private void underflowAndFloor(boolean shared) { } } + @Test void foldedSnapshotReplacementCannotAuthorizeASecondInFlightDebit() throws Exception { + for (boolean failFirst : new boolean[] { false, true }) { + var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); var attempts = new AtomicInteger(); + var stored = new ConcurrentHashMap(); stored.put("Points", new DataValueInt(1)); + var cache = new UserDataCache(manager, UUID.randomUUID()); + cache.configureSharedStorage(values -> { + if (attempts.getAndIncrement() == 0) { entered.countDown(); await(release); if (failFirst) throw new IllegalStateException("write failure"); } + stored.putAll(values); + }, Runnable::run, Runnable::run, com.bencodez.advancedcore.api.user.UserStorage.MYSQL, true); + cache.updateCache(new HashMap<>(stored)); var executor = Executors.newSingleThreadExecutor(); + try { + assertTrue(cache.tryMutateIntegers(Map.of("Points", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + var flush = executor.submit(cache::processChangesForSharedRuntime); assertTrue(entered.await(5, TimeUnit.SECONDS)); + cache.updateCache(new HashMap<>(Map.of("points", new DataValueInt(1)))); + assertFalse(cache.tryMutateIntegers(Map.of("Points", -1), IntegerMutation.DEBIT_POSITIVE, null).applied()); + release.countDown(); + if (failFirst) { assertThrows(ExecutionException.class, () -> flush.get(5, TimeUnit.SECONDS)); cache.processChangesForSharedRuntime(); } + else flush.get(5, TimeUnit.SECONDS); + assertEquals(0, stored.get("Points").getInt()); assertEquals(0, cache.getIfPresent("Points").getInt()); + assertEquals(0, cache.getIfPresent("points").getInt()); + } finally { release.countDown(); executor.shutdownNow(); } + } + } + @Test void newerQueuedAwardTakesPrecedenceOverInFlightDebitAfterReplacement() throws Exception { var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); @@ -597,7 +795,7 @@ private void underflowAndFloor(boolean shared) { var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(mock(ScheduledExecutorService.class)); doThrow(new RejectedExecutionException("notification executor unavailable")) - .when(manager).dispatchSharedUserDataNotification(any()); + .when(manager).dispatchSharedMutationNotification(any()); var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(values -> {}, Runnable::run); cache.updateCache(new HashMap<>(Map.of("a", new DataValueInt(1)))); @@ -781,7 +979,7 @@ private UserDataCache cache() { var plugin = mock(AdvancedCorePlugin.class); var manager = mock(UserDataManager.class); var timer = mock(ScheduledExecutorService.class); when(manager.getPlugin()).thenReturn(plugin); when(manager.getTimer()).thenReturn(timer); - doAnswer(call -> { call.getArgument(0).run(); return null; }).when(manager).dispatchSharedUserDataNotification(any()); + doAnswer(call -> { call.getArgument(0).run(); return null; }).when(manager).dispatchSharedMutationNotification(any()); var cache = new UserDataCache(manager, UUID.randomUUID()); cache.configureSharedStorage(values -> {}, Runnable::run); var values = new HashMap(); values.put("a", new DataValueInt(1)); values.put("b", new DataValueInt(2)); 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 index e706785ac5..7d75ec327b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java @@ -44,6 +44,12 @@ @Timeout(15) class SharedCacheBindingRegressionTest { + @Test void bukkitPostgresUsesConservativeSchemaIdentity() { + var plugin = mock(AdvancedCorePlugin.class); var table = mock(com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL.class); + when(table.caseInsensitiveColumnNames()).thenReturn(false); + var backend = new com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend(plugin, UserStorage.MYSQL, table, null); + assertNull(backend.caseInsensitiveColumnNames()); + } @Test void mariaNativeScalarRejectsOlderAliasAfterNewerCommit() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.MARIADB, false, 10); } @Test void mariaNativeBulkRejectsOlderAliasAfterNewerCommit() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.MARIADB, true, 10); } @Test void postgresNativeScalarKeepsDistinctAliases() throws Exception { nativeAliasOrdering(com.bencodez.simpleapi.sql.mysql.DbType.POSTGRESQL, false, 2); } @@ -1408,7 +1414,9 @@ private void mysqlSnapshotAliases(boolean folds) { manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); try { var pending = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var drained = manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture(); assertThrows(java.util.concurrent.ExecutionException.class, () -> pending.get(8, TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.ExecutionException.class, () -> drained.get(5, TimeUnit.SECONDS)); manager.endLegacyDirectWrite(direct); direct = null; assertEquals(1, cache.getIfPresent("Activations").getInt()); assertTrue(manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture().isDone()); @@ -1467,6 +1475,41 @@ private void mysqlSnapshotAliases(boolean folds) { assertEquals(2, cache.getIfPresent("Activations").getInt()); } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } } + @Test void legacyCompletionCannotBlockTheOnlyWorkerWaitingForLaterReceipt() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var table = mock(com.bencodez.advancedcore.api.user.userstorage.sql.UserTable.class); + var user = mock(AdvancedCoreUser.class); var data = mock(UserData.class); + when(plugin.getUserManager()).thenReturn(users); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getSQLiteUserTable()).thenReturn(table); + when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); + var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); + when(users.getUser(uuid, false)).thenReturn(user); when(user.getUserData()).thenReturn(data); + when(data.supportsStrictCompleteValues()).thenReturn(true); + when(data.getValuesIfStrictComplete()).thenReturn(new HashMap<>(Map.of("Points", new DataValueInt(1)))); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var direct = manager.beginLegacyDirectWrite(uuid); + try { + var successor = new java.util.concurrent.atomic.AtomicReference>(); + var notified = new java.util.concurrent.CompletableFuture(); + var first = manager.submitLegacyIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, keys -> { + try { java.util.concurrent.CompletableFuture.allOf(successor.get()).get(3, TimeUnit.SECONDS); notified.complete(null); } + catch (Exception e) { notified.completeExceptionally(e); } + }).toCompletableFuture(); + var second = manager.submitLegacyIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + successor.set(second); + var observer = first.whenComplete((value, failure) -> { + assertTrue(second.thenApply(result -> result).join().applied()); + assertTrue(second.minimalCompletionStage().toCompletableFuture().join().applied()); + assertThrows(IllegalStateException.class, () -> manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.shutdownLegacyIntegerMutations().toCompletableFuture().join()); + }); + manager.endLegacyDirectWrite(direct); direct = null; + notified.get(5, TimeUnit.SECONDS); observer.get(5, TimeUnit.SECONDS); second.get(5, TimeUnit.SECONDS); + assertEquals(3, cache.getIfPresent("Points").getInt()); + assertTrue(manager.submitLegacyIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture().get(5, TimeUnit.SECONDS).applied()); + assertEquals(4, cache.getIfPresent("Points").getInt()); + } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } @Test void sharedAwardReceiptDrainsBeforeRuntimeOwnerCloses() throws Exception { var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); @@ -1490,6 +1533,93 @@ private void mysqlSnapshotAliases(boolean folds) { shutdown.get(5, TimeUnit.SECONDS); assertEquals(2, cache.getIfPresent("Activations").getInt()); verify(backend).close(); } finally { release.countDown(); manager.getTimer().shutdownNow(); } } + @Test void sharedCompletionCannotBlockTheOnlyWorkerWaitingForLaterReceipt() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var finalized = manager.afterSharedMutationFinalization(first).whenComplete((value, failure) -> + assertTrue(second.join().applied())).toCompletableFuture(); + var observer = first.whenComplete((value, failure) -> { + assertTrue(second.thenApply(result -> result).join().applied()); + assertTrue(second.minimalCompletionStage().toCompletableFuture().join().applied()); + assertThrows(IllegalStateException.class, () -> manager.pendingSharedIntegerMutations(uuid).toCompletableFuture().join()); + assertThrows(IllegalStateException.class, () -> manager.afterSharedMutationFinalization(first).toCompletableFuture().join()); + }); + release.countDown(); observer.get(5, TimeUnit.SECONDS); finalized.get(5, TimeUnit.SECONDS); second.get(5, TimeUnit.SECONDS); + assertEquals(3, cache.getIfPresent("Points").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void rejectedReplayRetryRemainsUncertainAndDoesNotInventAnOutcome() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.supportsAtomicUserTransactions()).thenReturn(true); + var runtime = mock(SharedUserDataRuntime.class); when(runtime.backend()).thenReturn(backend); + when(runtime.transaction(any(UUID.class), eq(UserStorage.SQLITE), anyMap(), any())) + .thenThrow(new IllegalStateException("synthetic uncertain commit")); + manager.bindSharedRuntime(runtime); + var original = manager.getTimer(); var executor = mock(ScheduledExecutorService.class); + var task = new java.util.concurrent.atomic.AtomicReference(); + doAnswer(call -> { task.set(call.getArgument(0)); return null; }).when(executor).execute(any(Runnable.class)); + doThrow(new java.util.concurrent.RejectedExecutionException("synthetic retry rejection")) + .when(executor).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + Field timer = UserDataManager.class.getDeclaredField("timer"); timer.setAccessible(true); timer.set(manager, executor); + try { + UUID uuid = UUID.randomUUID(); String identity = "award:diagnostic"; + manager.acquireSharedReplayLease(uuid, identity); + var receipt = manager.submitSharedReplayTransaction(uuid, UserStorage.SQLITE, identity, Map.of(), scope -> null).toCompletableFuture(); + var callback = new java.util.concurrent.atomic.AtomicBoolean(); receipt.whenComplete((value, failure) -> callback.set(true)); + assertNotNull(task.get()); task.get().run(); + assertFalse(receipt.isDone(), "an unknown commit is not a definite failure or success"); + assertFalse(callback.get(), "diagnosis must not trigger lease settlement callbacks"); + assertEquals(java.util.List.of(new UserDataManager.IndeterminateReplay(uuid, identity, false)), manager.indeterminateSharedReplays()); + UUID other = UUID.randomUUID(); String checkpoint = "award:checkpoint"; + manager.acquireSharedReplayCheckpointLease(other, checkpoint); + var cleanup = manager.submitSharedReplayCheckpoint(other, UserStorage.SQLITE, checkpoint, Map.of(), scope -> null).toCompletableFuture(); + task.get().run(); assertFalse(cleanup.isDone()); + assertTrue(manager.indeterminateSharedReplays().contains(new UserDataManager.IndeterminateReplay(other, checkpoint, true))); + assertDoesNotThrow(() -> manager.requireSharedReplayAwardActive(other, checkpoint), "uncertain cleanup must not mark its lease checkpointed"); + assertFalse(manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().isDone()); + verify(runtime, times(2)).transaction(any(UUID.class), eq(UserStorage.SQLITE), anyMap(), any()); + verify(runtime, never()).closeAsync(any()); + } finally { timer.set(manager, original); original.shutdownNow(); } + } + @Test void rejectedCheckpointDoesNotFenceAwardAsCleanedUp() throws Exception { + var manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + var backend = mock(SqlUserBackend.class); when(backend.supportsAtomicUserTransactions()).thenReturn(true); + var runtime = mock(SharedUserDataRuntime.class); when(runtime.backend()).thenReturn(backend); + when(runtime.transaction(any(UUID.class), eq(UserStorage.SQLITE), anyMap(), any())) + .thenThrow(new com.bencodez.advancedcore.core.user.storage.SqlUserStorage.ReplayPreconditionException("checkpoint rejected")); + manager.bindSharedRuntime(runtime); UUID uuid = UUID.randomUUID(); String identity = "checkpoint-rejection"; + try { + manager.acquireSharedReplayLease(uuid, identity); + manager.acquireSharedReplayCheckpointLease(uuid, identity); + var receipt = manager.submitSharedReplayCheckpoint(uuid, UserStorage.SQLITE, identity, Map.of(), scope -> null).toCompletableFuture(); + assertThrows(java.util.concurrent.ExecutionException.class, () -> receipt.get(5, TimeUnit.SECONDS)); + assertDoesNotThrow(() -> manager.requireSharedReplayAwardActive(uuid, identity)); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void checkpointCannotConsumeAnAwardAttemptWithoutItsOwnReservation() { + var manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + var backend = mock(SqlUserBackend.class); when(backend.supportsAtomicUserTransactions()).thenReturn(true); + var runtime = mock(SharedUserDataRuntime.class); when(runtime.backend()).thenReturn(backend); + manager.bindSharedRuntime(runtime); UUID uuid = UUID.randomUUID(); String identity = "paired-checkpoint"; + try { + manager.acquireSharedReplayLease(uuid, identity); + assertThrows(IllegalStateException.class, () -> manager.submitSharedReplayCheckpoint(uuid, UserStorage.SQLITE, identity, Map.of(), scope -> null)); + assertDoesNotThrow(() -> manager.requireSharedReplayAwardActive(uuid, identity)); + manager.acquireSharedReplayCheckpointLease(uuid, identity); + manager.cancelSharedReplayCheckpointReservation(uuid, identity); + assertDoesNotThrow(() -> manager.requireSharedReplayAwardActive(uuid, identity)); + verify(runtime, never()).transaction(any(UUID.class), any(), anyMap(), any()); + } finally { manager.getTimer().shutdownNow(); } + } @Test void sharedAwardShutdownFlushesToActualBackendBeforeClose() throws Exception { try (Fixture fixture = new Fixture()) { var runtime = fixture.runtime(); fixture.manager.bindSharedRuntime(runtime); @@ -1499,6 +1629,7 @@ private void mysqlSnapshotAliases(boolean folds) { var shutdown = fixture.manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture(); assertFalse(shutdown.isDone()); fixture.tasks.remove(0).run(); assertTrue(receipt.get(5, TimeUnit.SECONDS).applied()); assertFalse(shutdown.isDone()); + fixture.manager.afterSharedMutationFinalization(receipt).toCompletableFuture().get(5, TimeUnit.SECONDS); fixture.tasks.remove(fixture.tasks.size() - 1).run(); assertFalse(shutdown.isDone(), "retirement must wait for the separately scheduled final flush"); fixture.tasks.remove(fixture.tasks.size() - 1).run(); shutdown.get(5, TimeUnit.SECONDS); @@ -1590,6 +1721,135 @@ private void mysqlSnapshotAliases(boolean folds) { assertEquals(3, cache.getIfPresent("Points").getInt()); } finally { release.countDown(); manager.getTimer().shutdownNow(); } } + @Test void sharedCompletionCanAwaitSameUserSuccessorThroughAllOf() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var workerEntered = new java.util.concurrent.CountDownLatch(1); var releaseWorker = new java.util.concurrent.CountDownLatch(1); + manager.getTimer().execute(() -> { workerEntered.countDown(); try { releaseWorker.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(workerEntered.await(5, TimeUnit.SECONDS)); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var second = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var callback = first.thenApply(value -> { try { java.util.concurrent.CompletableFuture.allOf(second).get(2, TimeUnit.SECONDS); return value; } catch (Exception e) { throw new java.util.concurrent.CompletionException(e); } }); + releaseWorker.countDown(); + assertTrue(callback.get(5, TimeUnit.SECONDS).applied()); + assertEquals(3, cache.getIfPresent("Points").getInt()); + } finally { releaseWorker.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void sharedMutationListenerCanAwaitQueuedSuccessor() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + manager.getTimer().execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + var successor = new java.util.concurrent.atomic.AtomicReference>(); + var notified = new java.util.concurrent.CompletableFuture(); + try { + assertTrue(entered.await(5, TimeUnit.SECONDS)); + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, keys -> { + try { java.util.concurrent.CompletableFuture.allOf(successor.get()).get(3, TimeUnit.SECONDS); notified.complete(null); } + catch (Exception e) { notified.completeExceptionally(e); } + }); + successor.set(manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture()); + release.countDown(); first.toCompletableFuture().get(5, TimeUnit.SECONDS); notified.get(5, TimeUnit.SECONDS); + assertEquals(3, cache.getIfPresent("Points").getInt()); + } finally { release.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void sharedMutationListenerReentersAfterAdmissionAndRefusedCloseKeepsNotifications() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var firstNotification = new java.util.concurrent.CompletableFuture(); var secondNotification = new java.util.concurrent.CompletableFuture(); + try { + var first = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, keys -> { + try { + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).applied()); + assertThrows(java.util.concurrent.CompletionException.class, () -> manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().join()); + firstNotification.complete(null); + } catch (Throwable failure) { firstNotification.completeExceptionally(failure); } + }); + first.toCompletableFuture().get(5, TimeUnit.SECONDS); firstNotification.get(5, TimeUnit.SECONDS); + manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, + keys -> secondNotification.complete(null)).toCompletableFuture().get(5, TimeUnit.SECONDS); + secondNotification.get(5, TimeUnit.SECONDS); assertEquals(4, cache.getIfPresent("Points").getInt()); verify(backend, never()).close(); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void directSharedMutationReleasesAdmissionBeforeNotificationSubmission() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = spy(new UserDataManager(plugin)); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + Field active = UserDataManager.class.getDeclaredField("sharedIntegerActiveUsers"); active.setAccessible(true); + var observed = new java.util.concurrent.atomic.AtomicBoolean(); + doAnswer(call -> { synchronized (manager) { observed.set(!((Map) active.get(manager)).containsKey(uuid)); } return call.callRealMethod(); }) + .when(manager).dispatchSharedMutationNotification(any(Runnable.class)); + try { + assertTrue(cache.tryMutateIntegers(Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, keys -> {}).applied()); + assertTrue(observed.get(), "notification must be submitted after the synchronous admission ends"); + } finally { manager.getTimer().shutdownNow(); } + } + @Test void sharedObserverAndShutdownWaitForPreviouslyQueuedCallback() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + var workerEntered = new CountDownLatch(1); var workerRelease = new CountDownLatch(1); + var callbackEntered = new CountDownLatch(1); var callbackRelease = new CountDownLatch(1); + manager.getTimer().execute(() -> { workerEntered.countDown(); try { workerRelease.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + try { + assertTrue(workerEntered.await(5, TimeUnit.SECONDS)); + var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + receipt.whenComplete((value, failure) -> { callbackEntered.countDown(); try { callbackRelease.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + var observer = manager.pendingSharedIntegerMutations(uuid).toCompletableFuture(); + var shutdown = manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture(); + workerRelease.countDown(); assertTrue(callbackEntered.await(5, TimeUnit.SECONDS)); + assertFalse(observer.isDone()); assertFalse(shutdown.isDone()); verify(backend, never()).close(); + callbackRelease.countDown(); observer.get(5, TimeUnit.SECONDS); shutdown.get(5, TimeUnit.SECONDS); verify(backend).close(); + } finally { workerRelease.countDown(); callbackRelease.countDown(); manager.getTimer().shutdownNow(); } + } + @Test void rejectedSharedCallbackDeliveryRetainsAdmissionWithoutInlineFallback() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + Field field = UserDataManager.class.getDeclaredField("mutationCallbacks"); field.setAccessible(true); + var lane = (java.util.concurrent.ThreadPoolExecutor) field.get(manager); + var entered = new java.util.concurrent.CountDownLatch(4); var release = new java.util.concurrent.CountDownLatch(1); + for (int i = 0; i < 4; i++) lane.execute(() -> { entered.countDown(); try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + for (int i = 0; i < 768; i++) lane.execute(() -> {}); + try { + var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + var callback = new java.util.concurrent.atomic.AtomicBoolean(); receipt.whenComplete((value, failure) -> callback.set(true)); + manager.getTimer().submit(() -> {}).get(5, TimeUnit.SECONDS); + assertEquals(2, cache.getIfPresent("Points").getInt()); assertFalse(receipt.isDone()); assertFalse(callback.get()); + assertTrue(manager.hasUnsettledLegacyIntegerMutations()); + assertFalse(manager.closeSharedRuntimeAsyncCompletion(() -> {}).toCompletableFuture().isDone()); + assertInstanceOf(java.util.concurrent.RejectedExecutionException.class, manager.getLastDeferredStorageFailure()); + } finally { release.countDown(); lane.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void lateReceiptCallbackCanRunOnRegisteringThread() throws Exception { + var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); + var backend = mock(SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); + manager.bindSharedRuntime(new SharedUserDataRuntime(backend, mock(UserCacheOwner.class))); + UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.configureSharedStorage(values -> {}, Runnable::run); + cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(1)))); manager.getUserDataCache().put(uuid, cache); + try { + var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); + receipt.get(5, TimeUnit.SECONDS); Thread registering = Thread.currentThread(); + assertSame(registering, receipt.thenApply(value -> Thread.currentThread()).get(5, TimeUnit.SECONDS)); + } finally { manager.getTimer().shutdownNow(); } + } @Test void pendingSharedObserverPropagatesAnAdmittedOperationFailure() throws Exception { var plugin = mock(AdvancedCorePlugin.class); var manager = new UserDataManager(plugin); var backend = mock(com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend.class); when(backend.isOpen()).thenReturn(true); @@ -1620,6 +1880,7 @@ private void mysqlSnapshotAliases(boolean folds) { var receipt = manager.submitSharedIntegerMutation(uuid, Map.of("Points", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); var observer = receipt.handle((value, failure) -> { assertNotNull(failure); assertFalse(Thread.holdsLock(manager)); return null; }); manager.getTimer().shutdownNow(); end.invoke(manager, uuid); observer.get(5, TimeUnit.SECONDS); + manager.afterSharedMutationFinalization(receipt).toCompletableFuture().get(5, TimeUnit.SECONDS); assertFalse(manager.hasUnsettledLegacyIntegerMutations()); } finally { manager.getTimer().shutdownNow(); } } @@ -1648,7 +1909,7 @@ private void mysqlSnapshotAliases(boolean folds) { var owner = mock(com.bencodez.advancedcore.core.user.runtime.UserCacheOwner.class); manager.bindSharedRuntime(new com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime(backend, owner)); try { - assertThrows(UnsupportedOperationException.class, () -> manager.submitSharedReplayTransaction(UUID.randomUUID(), UserStorage.SQLITE, Map.of(), scope -> null)); + assertThrows(UnsupportedOperationException.class, () -> manager.submitSharedReplayTransaction(UUID.randomUUID(), UserStorage.SQLITE, "fixture-operation", Map.of(), scope -> null)); assertFalse(manager.hasUnsettledLegacyIntegerMutations()); verify(backend, never()).user(any()); } finally { manager.getTimer().shutdownNow(); } } @@ -1731,17 +1992,23 @@ private void mysqlSnapshotAliases(boolean folds) { when(plugin.getNativeUserStorageOwner()).thenReturn(new AdvancedCorePlugin.UserStorageOwner(UserStorage.SQLITE, null, table)); var manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); var cache = new UserDataCache(manager, uuid); cache.updateCompleteCachePreservingPending(new HashMap<>(Map.of("Activations", new DataValueInt(1)))); - manager.getUserDataCache().put(uuid, cache); var direct = manager.beginLegacyDirectWrite(uuid); + manager.getUserDataCache().put(uuid, cache); + var timer = mock(ScheduledExecutorService.class); var task = new java.util.concurrent.atomic.AtomicReference(); + var schedules = new java.util.concurrent.atomic.AtomicInteger(); + doAnswer(call -> { if (schedules.getAndIncrement() == 0) { task.set(call.getArgument(0)); return null; } throw new java.util.concurrent.RejectedExecutionException("successor rejected"); }) + .when(timer).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + Field timerField = UserDataManager.class.getDeclaredField("timer"); timerField.setAccessible(true); var original = manager.getTimer(); timerField.set(manager, timer); try { - var first = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, - keys -> manager.getTimer().shutdown()).toCompletableFuture(); + var first = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 1), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); var second = manager.submitLegacyIntegerMutation(uuid, Map.of("Activations", 2), UserDataCache.IntegerMutation.ADD, null).toCompletableFuture(); - manager.endLegacyDirectWrite(direct); direct = null; + var drained = manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture(); + assertNotNull(task.get()); task.get().run(); assertTrue(first.get(5, TimeUnit.SECONDS).applied()); assertThrows(java.util.concurrent.ExecutionException.class, () -> second.get(5, TimeUnit.SECONDS)); + assertThrows(java.util.concurrent.ExecutionException.class, () -> drained.get(5, TimeUnit.SECONDS)); assertEquals(2, cache.getIfPresent("Activations").getInt()); assertTrue(manager.pendingLegacyIntegerMutations(uuid).toCompletableFuture().isDone()); - } finally { if (direct != null) manager.endLegacyDirectWrite(direct); manager.getTimer().shutdownNow(); } + } finally { timerField.set(manager, original); original.shutdownNow(); } } @Test void runtimeShutdownKeepsStorageOwnerUntilPendingAwardFlushes() throws Exception { var plugin = mock(AdvancedCorePlugin.class); var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); @@ -2944,6 +3211,53 @@ public UserDataCache getCache(UUID requested) { } } + @Test void managerWideClearWaitsForAnAlreadyAdmittedSetterBeforeMarkingRemoval() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var workers = Executors.newFixedThreadPool(2); + var access = SharedUserDataRuntime.class.getDeclaredMethod("userAccess", UUID.class, java.util.function.Supplier.class); access.setAccessible(true); + Field cellsField = SharedUserDataRuntime.class.getDeclaredField("userLocks"); cellsField.setAccessible(true); + try { + var writer = workers.submit(() -> access.invoke(runtime, fixture.uuid, (java.util.function.Supplier) () -> { + entered.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + cache.addChange(new UserDataChangeInt("Points", 42), true); return null; + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + Object cell = ((Map) cellsField.get(runtime)).get(fixture.uuid); + Field lockField = cell.getClass().getDeclaredField("lock"); lockField.setAccessible(true); + var lock = (java.util.concurrent.locks.ReentrantReadWriteLock) lockField.get(cell); + var clear = workers.submit(fixture.manager::clearCache); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!lock.hasQueuedThreads() && System.nanoTime() < deadline) java.util.concurrent.locks.LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(1)); + assertTrue(lock.hasQueuedThreads(), "retirement must be waiting for the admitted setter"); + release.countDown(); writer.get(5, TimeUnit.SECONDS); clear.get(5, TimeUnit.SECONDS); + assertEquals(42, fixture.first.points(fixture.uuid)); + assertThrows(IllegalStateException.class, () -> cache.addChange(new UserDataChangeInt("Points", 99), true)); + } finally { release.countDown(); workers.shutdownNow(); workers.awaitTermination(5, TimeUnit.SECONDS); } + runtime.close(); + } + } + + @Test void removalListenerFailureCannotReopenDetachedSharedCache() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + Field listener = UserDataManager.class.getDeclaredField("sharedCacheRemovalListener"); listener.setAccessible(true); + Object original = listener.get(fixture.manager); + listener.set(fixture.manager, (java.util.function.Consumer) id -> { throw new IllegalStateException("listener failed"); }); + try { + assertThrows(IllegalStateException.class, fixture.manager::clearCache); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertThrows(IllegalStateException.class, () -> cache.addChange(new UserDataChangeInt("Points", 99), true)); + } finally { listener.set(fixture.manager, original); } + runtime.close(); + } + } + @Test void failedManagerWideClearReopensEveryStillMappedCache() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); when(plugin.getNativeUserStorageOwner()).thenReturn(null); 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 index 1d5bc09dec..10be1abf49 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java @@ -37,6 +37,46 @@ /** Real cache/queue and runtime; controlled in-memory provider, no live server/database. */ @Timeout(15) class SharedCachePopulationRaceTest { + @Test void placeholderWinningDuringSynchronousPopulationIsRefreshed() throws Exception { + var plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + var user = mock(com.bencodez.advancedcore.api.user.AdvancedCoreUser.class); + var data = mock(com.bencodez.advancedcore.api.user.UserData.class); + var backend = mock(SqlUserBackend.class); UUID uuid = UUID.randomUUID(); + var entered = new CountDownLatch(1); var release = new CountDownLatch(1); + var calls = new java.util.concurrent.atomic.AtomicInteger(); + when(plugin.getUserManager()).thenReturn(users); when(users.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); when(data.getKeys()).thenAnswer(call -> new ArrayList()); + when(data.getValues()).thenAnswer(call -> { if (calls.getAndIncrement() == 0) { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); } return values(7, 2); }); + when(data.getValuesIfComplete()).thenAnswer(call -> { if (calls.getAndIncrement() == 0) { entered.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); } return values(7, 2); }); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); when(backend.isOpen()).thenReturn(true); + doReturn(null).when(plugin).getNativeUserStorageOwner(); + UserDataManager manager = new UserDataManager(plugin); + manager.bindSharedSqlBackend(backend, (id, operation) -> operation.run()); + var worker = Executors.newSingleThreadExecutor(); + try { + var population = worker.submit(() -> manager.cacheUser(uuid)); + if (!entered.await(5, TimeUnit.SECONDS)) { population.get(1, TimeUnit.SECONDS); fail("population did not read"); } + UserDataCache placeholder = new UserDataCache(manager, uuid); + manager.getUserDataCache().put(uuid, placeholder); + release.countDown(); population.get(5, TimeUnit.SECONDS); + assertSame(placeholder, manager.getUserDataCache().get(uuid)); + assertEquals(7, placeholder.getIfPresent("Points").getInt()); + } finally { release.countDown(); worker.shutdownNow(); manager.getTimer().shutdownNow(); } + } + @Test void refusedRetirementDoesNotDestroyTheStillMappedCache() { + UserDataManager manager = mock(UserDataManager.class); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + var caches = new ConcurrentHashMap(); caches.put(uuid, cache); + when(manager.getUserDataCache()).thenReturn(caches); + when(manager.retireSharedCacheAfterFlush(uuid, cache)).thenReturn(false); + var owner = new BukkitUserCacheOwner(manager); + assertThrows(IllegalStateException.class, () -> owner.remove(uuid)); + assertSame(cache, caches.get(uuid)); + verify(cache, never()).retireAfterSharedFlush(); + } @Test void pendingWriteWinsOverAnOlderDatabaseSnapshot() throws Exception { queuedWrite(false); } @Test void writeFlushedBeforePublicationStillWinsOverAnOlderSnapshot() throws Exception { queuedWrite(true); } @@ -179,6 +219,12 @@ private static final class Fixture implements AutoCloseable { Fixture() { when(manager.getPlugin()).thenReturn(plugin); when(manager.getUserDataCache()).thenReturn(caches); + when(manager.retireSharedCacheAfterFlush(any(UUID.class), any(UserDataCache.class))).thenAnswer(call -> { + UUID id = call.getArgument(0); UserDataCache expected = call.getArgument(1); + if (caches.get(id) != expected) return false; + expected.retireAfterSharedFlush(); + return caches.remove(id, expected); + }); when(manager.isCached(any(UUID.class))).thenAnswer(call -> { UserDataCache current = caches.get(call.getArgument(0, UUID.class)); return current != null && current.hasCache(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java index 339631ed15..a4501c455b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java @@ -476,11 +476,11 @@ private static final class Fixture { UserDataCache cache = caches.get(call.getArgument(0)); return cache != null && cache.hasCache(); }); - when(manager.retireSharedCache(any(UUID.class), nullable(UserDataCache.class))).thenAnswer(call -> { + when(manager.retireSharedCacheAfterFlush(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); + if (expected != null) { expected.retireAfterSharedFlush(); caches.remove(cachedUuid, expected); } return true; }); ScheduledExecutorService timer = mock(ScheduledExecutorService.class); 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 b4b4f1ba05..b7299211df 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 @@ -32,6 +32,21 @@ *

*/ public class UserDataTest { + @Test + public void queuedSettersRejectRetiredCacheWithoutClaimingSuccess() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + when(user.getPlugin()).thenReturn(plugin); when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(user.isCached()).thenReturn(true); when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); when(manager.getUserDataCache()).thenReturn(new java.util.concurrent.ConcurrentHashMap<>()); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + UserData data = new UserData(user); + assertThrows(IllegalStateException.class, () -> data.setInt(UserStorage.SQLITE, "Points", 1, true)); + assertThrows(IllegalStateException.class, () -> data.setString(UserStorage.SQLITE, "Name", "value", true)); + verify(users, never()).onChange(any(), anyString()); + } @Test public void testClearTempCache_nullSafe() {