From 4417d55c016ed81102a72cc53ad3a4a6a3284889 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:36:35 -0600 Subject: [PATCH 01/34] Add shared user cache ownership port --- .../core/user/runtime/UserCacheOwner.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java new file mode 100644 index 000000000..2df60ab8a --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java @@ -0,0 +1,32 @@ +package com.bencodez.advancedcore.core.user.runtime; + +import java.util.HashMap; +import java.util.Set; +import java.util.UUID; + +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.simpleapi.sql.data.DataValue; + +/** + * Port implemented by the existing cache owner. The shared runtime deliberately + * does not allocate a second cache or queue. + */ +public interface UserCacheOwner { + boolean isCached(UUID uuid); + + DataValue getIfPresent(UUID uuid, String key); + + void populate(UUID uuid, HashMap values); + + void queueChange(UUID uuid, String key, DataValue value); + + void flush(UUID uuid, SqlUserStorage storage); + + Set cachedUsers(); + + void remove(UUID uuid); + + void clearAfterFlush(); + + void shutdown(); +} From 216acbf9639865befb0b1eccf4175fb93b0132a3 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:36:55 -0600 Subject: [PATCH 02/34] Add shared user data lifecycle runtime --- .../user/runtime/SharedUserDataRuntime.java | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java new file mode 100644 index 000000000..07214f234 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java @@ -0,0 +1,184 @@ +package com.bencodez.advancedcore.core.user.runtime; + +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; + +/** + * Platform-neutral user lifecycle coordinator. Cache and queue ownership stays + * with the injected {@link UserCacheOwner}; this class never allocates a second + * user cache or parallel user record. + */ +public final class SharedUserDataRuntime implements AutoCloseable { + private final UserCacheOwner cacheOwner; + private volatile SqlUserBackend backend; + private volatile boolean closed; + + public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) { + this.backend = Objects.requireNonNull(backend, "backend"); + this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); + } + + public DataValue read(UUID uuid, String key, UserDataFetchMode mode, + HashMap temporaryCache, DataValue defaultValue) { + requireOpen(); + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(mode, "mode"); + if (key == null || key.isEmpty()) { + return defaultValue; + } + + if (mode.allowTempCache() && temporaryCache != null) { + DataValue temporary = temporaryCache.get(key); + if (temporary != null) { + return temporary; + } + if (!mode.allowUserCache() && !mode.allowStorageLookup()) { + return defaultValue; + } + } + + if (mode.allowUserCache()) { + DataValue cached = cacheOwner.getIfPresent(uuid, key); + if (cached != null) { + return cached; + } + if (mode.waitForCache() && !cacheOwner.isCached(uuid)) { + populate(uuid); + cached = cacheOwner.getIfPresent(uuid, key); + if (cached != null) { + return cached; + } + } + if (!mode.allowStorageLookup()) { + return defaultValue; + } + } else if (!mode.allowStorageLookup()) { + return defaultValue; + } + + return find(readStorageRow(uuid), key, defaultValue); + } + + public HashMap populate(UUID uuid) { + requireOpen(); + HashMap values = SqlUserDataAccess.convert(readStorageRow(uuid)); + cacheOwner.populate(uuid, values); + return values; + } + + public int startupForEach(BiConsumer> consumer, boolean populateCache) { + requireOpen(); + Objects.requireNonNull(consumer, "consumer"); + int count = 0; + for (UUID uuid : backend.enumerateUsers()) { + HashMap values = SqlUserDataAccess.convert(readStorageRow(uuid)); + if (populateCache) { + cacheOwner.populate(uuid, values); + } + consumer.accept(uuid, values); + count++; + } + return count; + } + + public void queueChange(UUID uuid, String key, DataValue value) { + requireOpen(); + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(key, "key"); + if (!cacheOwner.isCached(uuid)) { + populate(uuid); + } + cacheOwner.queueChange(uuid, key, value); + } + + public void flush(UUID uuid) { + requireOpen(); + Objects.requireNonNull(uuid, "uuid"); + cacheOwner.flush(uuid, backend.user(uuid)); + } + + public void flushAll() { + requireOpen(); + Set users = Set.copyOf(cacheOwner.cachedUsers()); + for (UUID uuid : users) { + cacheOwner.flush(uuid, backend.user(uuid)); + } + } + + public synchronized void replaceBackend(SqlUserBackend replacement) { + requireOpen(); + Objects.requireNonNull(replacement, "replacement"); + if (!replacement.isOpen()) { + throw new IllegalArgumentException("replacement backend is closed"); + } + flushAll(); + cacheOwner.clearAfterFlush(); + SqlUserBackend previous = backend; + backend = replacement; + previous.close(); + } + + public void remove(UUID uuid) { + requireOpen(); + Objects.requireNonNull(uuid, "uuid"); + flush(uuid); + backend.user(uuid).delete(backend.storageType()); + cacheOwner.remove(uuid); + } + + public SqlUserBackend backend() { + return backend; + } + + public boolean isClosed() { + return closed; + } + + @Override + public synchronized void close() { + if (closed) { + return; + } + flushAll(); + cacheOwner.clearAfterFlush(); + cacheOwner.shutdown(); + backend.close(); + closed = true; + } + + private List readStorageRow(UUID uuid) { + SqlUserStorage storage = backend.user(uuid); + return storage.readRow(backend.storageType()); + } + + private DataValue find(List row, String key, DataValue defaultValue) { + if (row != null) { + for (Column column : row) { + if (column.getName().equals(key)) { + return column.getValue() == null ? defaultValue : column.getValue(); + } + } + } + return defaultValue; + } + + private void requireOpen() { + if (closed) { + throw new IllegalStateException("Shared user data runtime is closed"); + } + if (!backend.isOpen()) { + throw new IllegalStateException("SQL user backend is closed"); + } + } +} From b06565ac82ef8ad44b84a57476640ae1f63e0927 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:37:19 -0600 Subject: [PATCH 03/34] Adapt existing Bukkit user cache ownership --- .../user/runtime/BukkitUserCacheOwner.java | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java new file mode 100644 index 000000000..ba21ed6d3 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java @@ -0,0 +1,113 @@ +package com.bencodez.advancedcore.bukkit.user.runtime; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeBoolean; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.simpleapi.sql.data.DataValue; + +/** + * Reuses the existing UserDataManager map, UserDataCache instances and their + * queued-change ordering. No parallel Bukkit cache is introduced. + */ +public final class BukkitUserCacheOwner implements UserCacheOwner { + private final UserDataManager manager; + + public BukkitUserCacheOwner(UserDataManager manager) { + this.manager = Objects.requireNonNull(manager, "manager"); + } + + @Override + public boolean isCached(UUID uuid) { + return manager.isCached(uuid); + } + + @Override + public DataValue getIfPresent(UUID uuid, String key) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache == null || !cache.isCached(key) || cache.getCache() == null) { + return null; + } + return cache.getCache().get(key); + } + + @Override + public void populate(UUID uuid, HashMap values) { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, + ignored -> new UserDataCache(manager, uuid)); + cache.updateCache(values); + } + + @Override + public void queueChange(UUID uuid, String key, DataValue value) { + Objects.requireNonNull(value, "value"); + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache == null) { + throw new IllegalStateException("User must be populated before queuing a change: " + uuid); + } + cache.addChange(change(key, value), true); + } + + @Override + public void flush(UUID uuid, SqlUserStorage storage) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) { + // UserDataCache remains the queue owner and preserves existing notification, + // batching and write-order behavior. The storage argument is for headless owners. + cache.processChanges(); + } + } + + @Override + public Set cachedUsers() { + return new HashSet<>(manager.getUserDataCache().keySet()); + } + + @Override + public void remove(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().remove(uuid); + if (cache != null) { + cache.clearCache(); + cache.dump(); + } + } + + @Override + public void clearAfterFlush() { + manager.clearCache(); + } + + @Override + public void shutdown() { + manager.getTimer().shutdown(); + try { + if (!manager.getTimer().awaitTermination(10, TimeUnit.SECONDS)) { + manager.getTimer().shutdownNow(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + manager.getTimer().shutdownNow(); + } + } + + private UserDataChange change(String key, DataValue value) { + if (value.isInt()) { + return new UserDataChangeInt(key, value.getInt()); + } + if (value.isBoolean()) { + return new UserDataChangeBoolean(key, value.getBoolean()); + } + return new UserDataChangeString(key, value.getString()); + } +} From fda552cea136d285055a59e3f4b39a7ffafdb123 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 18:37:46 -0600 Subject: [PATCH 04/34] Test shared user lifecycle and queue semantics --- .../tests/user/SharedUserDataRuntimeTest.java | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java new file mode 100644 index 000000000..3e3fc4bc7 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java @@ -0,0 +1,279 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.sql.data.DataValueString; + +class SharedUserDataRuntimeTest { + @Test + void preservesTempCacheThenCacheThenStoragePrecedenceAcrossFetchModes() { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "value", new DataValueString("storage")); + FakeCacheOwner cache = new FakeCacheOwner(); + cache.populate(uuid, values("value", new DataValueString("cache"))); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + + HashMap temp = values("value", new DataValueString("temp")); + assertEquals("temp", runtime.read(uuid, "value", UserDataFetchMode.DEFAULT, temp, + new DataValueString("default")).getString()); + assertEquals("cache", runtime.read(uuid, "value", UserDataFetchMode.CACHE_ONLY, null, + new DataValueString("default")).getString()); + assertEquals("storage", runtime.read(uuid, "value", UserDataFetchMode.NO_CACHE, null, + new DataValueString("default")).getString()); + assertEquals("default", runtime.read(uuid, "missing", UserDataFetchMode.TEMP_ONLY, temp, + new DataValueString("default")).getString()); + assertEquals("default", runtime.read(uuid, "missing", UserDataFetchMode.NO_DB_LOOKUP, null, + new DataValueString("default")).getString()); + + UUID uncached = UUID.randomUUID(); + backend.put(uncached, "value", new DataValueString("direct")); + int populations = cache.populateCalls; + assertEquals("direct", runtime.read(uncached, "value", UserDataFetchMode.NO_WAIT, null, + new DataValueString("default")).getString()); + assertEquals(populations, cache.populateCalls); + } + + @Test + void startupEnumerationCanPopulateExistingCacheOwner() { + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(first, "Points", new DataValueInt(2)); + backend.put(second, "Points", new DataValueInt(7)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + Set seen = new HashSet<>(); + + int count = runtime.startupForEach((uuid, values) -> seen.add(uuid), true); + + assertEquals(2, count); + assertEquals(Set.of(first, second), seen); + assertEquals(2, cache.getIfPresent(first, "Points").getInt()); + assertEquals(7, cache.getIfPresent(second, "Points").getInt()); + } + + @Test + void queuedChangesFlushBeforeShutdownAndBackendReplacement() { + UUID uuid = UUID.randomUUID(); + FakeBackend first = new FakeBackend(); + first.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(first, cache); + + runtime.queueChange(uuid, "Points", new DataValueInt(5)); + runtime.flush(uuid); + assertEquals(5, first.value(uuid, "Points").getInt()); + + FakeBackend second = new FakeBackend(); + runtime.queueChange(uuid, "Points", new DataValueInt(9)); + runtime.replaceBackend(second); + assertEquals(9, first.value(uuid, "Points").getInt()); + assertFalse(first.isOpen()); + assertSame(second, runtime.backend()); + assertTrue(cache.cachedUsers().isEmpty()); + + runtime.queueChange(uuid, "Points", new DataValueInt(11)); + runtime.close(); + assertEquals(11, second.value(uuid, "Points").getInt()); + assertFalse(second.isOpen()); + assertTrue(cache.shutdown); + assertTrue(runtime.isClosed()); + } + + @Test + void failedFlushDoesNotDiscardQueueOrCloseBackend() { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + runtime.queueChange(uuid, "Points", new DataValueInt(3)); + backend.failWrites = true; + + assertThrows(IllegalStateException.class, runtime::close); + assertTrue(backend.isOpen()); + assertFalse(runtime.isClosed()); + assertTrue(cache.hasPending(uuid)); + + backend.failWrites = false; + runtime.close(); + assertEquals(3, backend.value(uuid, "Points").getInt()); + } + + private static HashMap values(String key, DataValue value) { + HashMap result = new HashMap<>(); + result.put(key, value); + return result; + } + + private static final class FakeCacheOwner implements UserCacheOwner { + private final Map> cache = new HashMap<>(); + private final Map> pending = new HashMap<>(); + private int populateCalls; + private boolean shutdown; + + @Override + public boolean isCached(UUID uuid) { + return cache.containsKey(uuid); + } + + @Override + public DataValue getIfPresent(UUID uuid, String key) { + HashMap values = cache.get(uuid); + return values == null ? null : values.get(key); + } + + @Override + public void populate(UUID uuid, HashMap values) { + populateCalls++; + cache.put(uuid, new HashMap<>(values)); + } + + @Override + public void queueChange(UUID uuid, String key, DataValue value) { + cache.computeIfAbsent(uuid, ignored -> new HashMap<>()).put(key, value); + pending.computeIfAbsent(uuid, ignored -> new HashMap<>()).put(key, value); + } + + @Override + public void flush(UUID uuid, SqlUserStorage storage) { + HashMap changes = pending.get(uuid); + if (changes == null || changes.isEmpty()) { + return; + } + storage.writeValues(UserStorage.SQLITE, new HashMap<>(changes)); + pending.remove(uuid); + } + + @Override + public Set cachedUsers() { + return new HashSet<>(cache.keySet()); + } + + @Override + public void remove(UUID uuid) { + cache.remove(uuid); + pending.remove(uuid); + } + + @Override + public void clearAfterFlush() { + cache.clear(); + } + + @Override + public void shutdown() { + shutdown = true; + } + + boolean hasPending(UUID uuid) { + return pending.containsKey(uuid) && !pending.get(uuid).isEmpty(); + } + } + + private static final class FakeBackend implements SqlUserBackend { + private final Map> rows = new LinkedHashMap<>(); + private boolean open = true; + private boolean failWrites; + + void put(UUID uuid, String key, DataValue value) { + rows.computeIfAbsent(uuid, ignored -> new LinkedHashMap<>()).put(key, value); + } + + DataValue value(UUID uuid, String key) { + Map row = rows.get(uuid); + return row == null ? null : row.get(key); + } + + @Override + public UserStorage storageType() { + return UserStorage.SQLITE; + } + + @Override + public SqlUserStorage user(UUID uuid) { + if (!open) { + throw new IllegalStateException("closed"); + } + return new SqlUserStorage() { + @Override + public List readRow(UserStorage storage) { + ArrayList result = new ArrayList<>(); + Map row = rows.get(uuid); + if (row != null) { + for (Map.Entry entry : row.entrySet()) { + result.add(new Column(entry.getKey(), entry.getValue())); + } + } + return result; + } + + @Override + public boolean contains(UserStorage storage) { + return rows.containsKey(uuid); + } + + @Override + public void delete(UserStorage storage) { + rows.remove(uuid); + } + + @Override + public void write(UserStorage storage, String key, DataValue value) { + if (failWrites) { + throw new IllegalStateException("write failed"); + } + put(uuid, key, value); + } + + @Override + public void writeValues(UserStorage storage, HashMap values) { + if (failWrites) { + throw new IllegalStateException("write failed"); + } + rows.computeIfAbsent(uuid, ignored -> new LinkedHashMap<>()).putAll(values); + } + }; + } + + @Override + public List enumerateUsers() { + return new ArrayList<>(rows.keySet()); + } + + @Override + public boolean isOpen() { + return open; + } + + @Override + public void close() { + open = false; + } + } +} From ee8c5e09b126d17fb734062cad3247db7705d05f Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 23:13:46 -0600 Subject: [PATCH 05/34] Coordinate shared cache writes, provider replacement and asynchronous shutdown Route explicit and scheduled batches through the existing UserDataCache queue and notification hooks with an explicit backend destination. Stop new admission before shutdown, drain admitted work including notifications, retain failed batches, and provide an observed worker-only closeAsync. Cancel redundant timer work without server-thread termination waits. Add nine lifecycle/adapter regressions with memory SQL providers and real cache code. Enable the normal Maven PR job for this stacked base so this head gets actual repository test and packaged-artifact validation. Local JDK21 isolated compilation/execution of the candidate runtime and cache adapter passed replacement, scheduled flush and notification/shutdown barrier checks (external APIs stubbed; Lombok getters expanded only in the isolated copy). Syntax and incremental whitespace checks passed. Full local Maven and live database/server checks remain unavailable. No dependency, shading, FLAT, native loader, or unrelated PR changes. --- .github/workflows/maven.yml | 2 +- .../api/user/usercache/UserDataCache.java | 128 ++++++-- .../user/runtime/BukkitUserCacheOwner.java | 108 ++++--- .../user/runtime/SharedUserDataRuntime.java | 268 ++++++++++------ .../core/user/runtime/UserCacheOwner.java | 27 +- .../SharedUserLifecycleRegressionTest.java | 293 ++++++++++++++++++ 6 files changed, 653 insertions(+), 173 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java diff --git a/.github/workflows/maven.yml b/.github/workflows/maven.yml index f9cb94287..a1b1badba 100644 --- a/.github/workflows/maven.yml +++ b/.github/workflows/maven.yml @@ -11,7 +11,7 @@ on: push: branches: [ "master" ] pull_request: - branches: [ "master" ] + branches: [ "master", "codex/shared-sql-backend-init" ] permissions: contents: write # required for dependency submission diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java index b2b60078b..a38cc6423 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 @@ -8,6 +8,7 @@ import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; @@ -26,6 +27,9 @@ public class UserDataCache { private final UserDataManager manager; private boolean scheduled = false; private int inFlightBatches = 0; + private volatile Consumer> sharedStorageWriter; + private Thread sharedBatchThread; + private volatile Consumer sharedFlushGate; @Getter private UUID uuid; @@ -36,7 +40,22 @@ public UserDataCache(UserDataManager manager, UUID uuid) { cache = new HashMap<>(); } - public synchronized void addChange(UserDataChange change, boolean queue) { + public void addChange(UserDataChange change, boolean queue) { + Consumer gate; + synchronized (this) { + gate = sharedFlushGate; + if (gate == null) { + addChangeInternal(change, queue); + return; + } + } + gate.accept(() -> addChangeInternal(change, queue)); + } + + 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"); + } if (change == null || cache == null || cachedChanges == null) { return; } @@ -85,19 +104,25 @@ public synchronized UserDataCache cache() { return this; } - public synchronized void clearCache() { - if (hasChangesToProcess()) { - processChanges(); - } - if (cache != null) { - cache.clear(); + public void clearCache() { + Consumer gate; + synchronized (this) { + gate = sharedFlushGate; + if (gate == null) { + if (hasChangesToProcess()) processChanges(); + if (cache != null) cache.clear(); + return; + } } + // Acquire the runtime gate BEFORE the cache monitor, including legacy cleanup callers. + gate.accept(() -> { + processChanges(); + synchronized (this) { if (cache != null) cache.clear(); } + }); } - public synchronized void clearChanges() { - if (hasChangesToProcess()) { - processChanges(); - } + public void clearChanges() { + if (hasChangesToProcess()) processChanges(); } public void displayCache() { @@ -162,19 +187,78 @@ public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } + /** Install the gate and destination together; never take over an active legacy batch. */ + public synchronized void configureSharedStorage(Consumer> writer, + Consumer gate) { + if (sharedFlushGate != null && sharedFlushGate != gate) { + throw new IllegalStateException("Shared user cache already belongs to another runtime"); + } + if (sharedFlushGate == null && gate != null && inFlightBatches != 0) { + throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); + } + setSharedStorageWriter(writer); + sharedFlushGate = gate; + } + + /** Bind shared storage without replacing the queue or legacy notification hooks. */ + public synchronized void setSharedStorageWriter(Consumer> writer) { + if (uuid == null || cachedChanges == null) throw new IllegalStateException("Shared user cache is retired"); + sharedStorageWriter = java.util.Objects.requireNonNull(writer, "writer"); + } + + /** Retire only after a successful drain; a racing write fails rather than disappearing. */ + public synchronized void retireAfterSharedFlush() { + if (inFlightBatches != 0 || (cachedChanges != null && !cachedChanges.isEmpty())) { + throw new IllegalStateException("Shared user cache has unflushed work"); + } + cache = null; + cachedChanges = null; + uuid = null; + scheduled = false; + } + public void processChanges() { - UUID currentUuid; + processChangesInternal(false); + } + + private void processChangesInternal(boolean admitted) { + UUID currentUuid = null; + Consumer> writer = null; + Consumer gate; ArrayList changes = new ArrayList<>(); synchronized (this) { - currentUuid = uuid; - if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) { - return; - } - UserDataChange change; - while ((change = cachedChanges.poll()) != null) { - changes.add(change); + gate = admitted ? null : sharedFlushGate; + if (gate == null) { + writer = sharedStorageWriter; + if (writer != null) { + // Scheduled and explicit shared flushes serialize the SAME queue, including + // an earlier batch whose values have already been drained from it. + if (sharedBatchThread == Thread.currentThread()) { + throw new IllegalStateException("Cannot flush a shared cache from its own change notification"); + } + while (inFlightBatches > 0) { + try { wait(); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while draining user changes", interrupted); + } + } + } + currentUuid = uuid; + if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) { + return; + } + UserDataChange change; + while ((change = cachedChanges.poll()) != null) { + changes.add(change); + } + inFlightBatches++; + if (writer != null) sharedBatchThread = Thread.currentThread(); } - inFlightBatches++; + } + if (gate != null) { + gate.accept(() -> processChangesInternal(true)); + return; } boolean persisted = false; @@ -188,7 +272,8 @@ public void processChanges() { keys.add(change.getKey()); } if (!values.isEmpty()) { - user.getUserData().setValues(values); + if (writer == null) user.getUserData().setValues(values); + else writer.accept(values); } persisted = true; manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(keys)); @@ -218,6 +303,7 @@ private synchronized void requeueChanges(ArrayList changes) { private synchronized void finishInFlightBatch() { if (inFlightBatches > 0) { inFlightBatches--; + if (sharedBatchThread == Thread.currentThread()) sharedBatchThread = null; } notifyAll(); } 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 ba21ed6d3..8b9e16af4 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 @@ -5,8 +5,12 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import org.bukkit.Bukkit; + +import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; @@ -15,37 +19,67 @@ import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString; import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.simpleapi.sql.data.DataValue; -/** - * Reuses the existing UserDataManager map, UserDataCache instances and their - * queued-change ordering. No parallel Bukkit cache is introduced. - */ +/** Reuses the existing manager, cache instances, queue and notification ordering. */ public final class BukkitUserCacheOwner implements UserCacheOwner { private final UserDataManager manager; + private volatile SqlUserBackend backend; + private Consumer flushGate; public BukkitUserCacheOwner(UserDataManager manager) { this.manager = Objects.requireNonNull(manager, "manager"); } @Override - public boolean isCached(UUID uuid) { - return manager.isCached(uuid); + public synchronized void bindFlushGate(Consumer gate) { + Objects.requireNonNull(gate, "gate"); + if (flushGate != null && flushGate != gate) { + throw new IllegalStateException("Cache owner already belongs to another runtime"); + } + flushGate = gate; + } + + @Override + public void bindBackend(SqlUserBackend backend) { + this.backend = Objects.requireNonNull(backend, "backend"); + manager.getUserDataCache().forEach((uuid, cache) -> bind(cache, uuid)); + } + + private void bind(UserDataCache cache, UUID uuid) { + SqlUserBackend selected = backend; + if (selected != null) { + // Capture this lifecycle, not plugin.getStorageType() or a later provider. + cache.configureSharedStorage(values -> + selected.user(uuid).writeValues(selected.storageType(), values), flushGate); + } + } + + @Override + public void requireBlockingAllowed() { + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Shared user storage must run on a worker; use closeAsync for shutdown"); + } } + @Override + public boolean isCached(UUID uuid) { return manager.isCached(uuid); } + @Override public DataValue getIfPresent(UUID uuid, String key) { UserDataCache cache = manager.getUserDataCache().get(uuid); - if (cache == null || !cache.isCached(key) || cache.getCache() == null) { - return null; + if (cache == null) return null; + synchronized (cache) { + return cache.getCache() == null ? null : cache.getCache().get(key); } - return cache.getCache().get(key); } @Override public void populate(UUID uuid, HashMap values) { UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); cache.updateCache(values); } @@ -53,61 +87,61 @@ public void populate(UUID uuid, HashMap values) { public void queueChange(UUID uuid, String key, DataValue value) { Objects.requireNonNull(value, "value"); UserDataCache cache = manager.getUserDataCache().get(uuid); - if (cache == null) { - throw new IllegalStateException("User must be populated before queuing a change: " + uuid); - } + if (cache == null) throw new IllegalStateException("User must be populated before queuing: " + uuid); + bind(cache, uuid); cache.addChange(change(key, value), true); } @Override public void flush(UUID uuid, SqlUserStorage storage) { + UserStorage type = backend == null ? manager.getPlugin().getStorageType() : backend.storageType(); + flush(uuid, type, storage); + } + + @Override + public void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache != null) { - // UserDataCache remains the queue owner and preserves existing notification, - // batching and write-order behavior. The storage argument is for headless owners. - cache.processChanges(); + cache.setSharedStorageWriter(values -> storage.writeValues(type, values)); + do { + // Includes the existing in-flight batch and its notifications before returning. + cache.processChanges(); + } while (cache.hasChangesToProcess()); } } @Override - public Set cachedUsers() { - return new HashSet<>(manager.getUserDataCache().keySet()); - } + public Set cachedUsers() { return new HashSet<>(manager.getUserDataCache().keySet()); } @Override public void remove(UUID uuid) { - UserDataCache cache = manager.getUserDataCache().remove(uuid); + UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache != null) { - cache.clearCache(); - cache.dump(); + // Never call the legacy clear/dump path, which can resolve the plugin's provider. + cache.retireAfterSharedFlush(); + manager.getUserDataCache().remove(uuid, cache); } } @Override public void clearAfterFlush() { - manager.clearCache(); + for (UUID uuid : cachedUsers()) remove(uuid); } @Override public void shutdown() { - manager.getTimer().shutdown(); - try { - if (!manager.getTimer().awaitTermination(10, TimeUnit.SECONDS)) { - manager.getTimer().shutdownNow(); - } - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - manager.getTimer().shutdownNow(); + // User data has already drained. Cancel redundant delayed/periodic tasks + // without awaiting the timer on a server thread or interrupting active JDBC. + if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { + timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); + timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); } + manager.getTimer().shutdown(); } private UserDataChange change(String key, DataValue value) { - if (value.isInt()) { - return new UserDataChangeInt(key, value.getInt()); - } - if (value.isBoolean()) { - return new UserDataChangeBoolean(key, value.getBoolean()); - } + if (value.isInt()) return new UserDataChangeInt(key, value.getInt()); + if (value.isBoolean()) return new UserDataChangeBoolean(key, value.getBoolean()); return new UserDataChangeString(key, value.getString()); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/SharedUserDataRuntime.java index 07214f234..582c95c59 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 @@ -5,161 +5,227 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; +import java.util.function.Supplier; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess; -import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.data.DataValue; -/** - * Platform-neutral user lifecycle coordinator. Cache and queue ownership stays - * with the injected {@link UserCacheOwner}; this class never allocates a second - * user cache or parallel user record. - */ +/** Coordinates one existing cache/queue and its SQL provider; storage work runs on a worker. */ public final class SharedUserDataRuntime implements AutoCloseable { private final UserCacheOwner cacheOwner; + private final ReentrantLock lifecycle = new ReentrantLock(true); + private final AtomicBoolean retiring = new AtomicBoolean(); + private final Object closeLock = new Object(); private volatile SqlUserBackend backend; private volatile boolean closed; + private CompletableFuture closeAttempt; public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) { this.backend = Objects.requireNonNull(backend, "backend"); this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); + cacheOwner.bindFlushGate(batch -> access(() -> { batch.run(); return null; })); + cacheOwner.bindBackend(backend); } public DataValue read(UUID uuid, String key, UserDataFetchMode mode, HashMap temporaryCache, DataValue defaultValue) { - requireOpen(); - Objects.requireNonNull(uuid, "uuid"); - Objects.requireNonNull(mode, "mode"); - if (key == null || key.isEmpty()) { - return defaultValue; - } - - if (mode.allowTempCache() && temporaryCache != null) { - DataValue temporary = temporaryCache.get(key); - if (temporary != null) { - return temporary; - } - if (!mode.allowUserCache() && !mode.allowStorageLookup()) { - return defaultValue; + return access(() -> { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(mode, "mode"); + if (key == null || key.isEmpty()) return defaultValue; + if (mode.allowTempCache() && temporaryCache != null) { + DataValue temporary = temporaryCache.get(key); + if (temporary != null) return temporary; + if (!mode.allowUserCache() && !mode.allowStorageLookup()) return defaultValue; } - } - - if (mode.allowUserCache()) { - DataValue cached = cacheOwner.getIfPresent(uuid, key); - if (cached != null) { - return cached; - } - if (mode.waitForCache() && !cacheOwner.isCached(uuid)) { - populate(uuid); - cached = cacheOwner.getIfPresent(uuid, key); - if (cached != null) { - return cached; + if (mode.allowUserCache()) { + DataValue cached = cacheOwner.getIfPresent(uuid, key); + if (cached != null) return cached; + if (mode.waitForCache() && !cacheOwner.isCached(uuid)) { + populateInternal(uuid); + cached = cacheOwner.getIfPresent(uuid, key); + if (cached != null) return cached; } - } - if (!mode.allowStorageLookup()) { + if (!mode.allowStorageLookup()) return defaultValue; + } else if (!mode.allowStorageLookup()) { return defaultValue; } - } else if (!mode.allowStorageLookup()) { - return defaultValue; - } - - return find(readStorageRow(uuid), key, defaultValue); + return find(readStorageRow(uuid), key, defaultValue); + }); } public HashMap populate(UUID uuid) { - requireOpen(); + return access(() -> populateInternal(Objects.requireNonNull(uuid, "uuid"))); + } + + private HashMap populateInternal(UUID uuid) { + if (cacheOwner.isCached(uuid)) flushInternal(uuid); HashMap values = SqlUserDataAccess.convert(readStorageRow(uuid)); cacheOwner.populate(uuid, values); return values; } public int startupForEach(BiConsumer> consumer, boolean populateCache) { - requireOpen(); - Objects.requireNonNull(consumer, "consumer"); - int count = 0; - for (UUID uuid : backend.enumerateUsers()) { - HashMap values = SqlUserDataAccess.convert(readStorageRow(uuid)); - if (populateCache) { - cacheOwner.populate(uuid, values); + return access(() -> { + Objects.requireNonNull(consumer, "consumer"); + int count = 0; + for (UUID uuid : backend.enumerateUsers()) { + HashMap values = populateCache ? populateInternal(uuid) + : SqlUserDataAccess.convert(readStorageRow(uuid)); + consumer.accept(uuid, values); + count++; } - consumer.accept(uuid, values); - count++; - } - return count; + return count; + }); } public void queueChange(UUID uuid, String key, DataValue value) { - requireOpen(); - Objects.requireNonNull(uuid, "uuid"); - Objects.requireNonNull(key, "key"); - if (!cacheOwner.isCached(uuid)) { - populate(uuid); - } - cacheOwner.queueChange(uuid, key, value); + access(() -> { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + if (!cacheOwner.isCached(uuid)) populateInternal(uuid); + cacheOwner.queueChange(uuid, key, value); + return null; + }); } public void flush(UUID uuid) { - requireOpen(); - Objects.requireNonNull(uuid, "uuid"); - cacheOwner.flush(uuid, backend.user(uuid)); + access(() -> { flushInternal(Objects.requireNonNull(uuid, "uuid")); return null; }); + } + + private void flushInternal(UUID uuid) { + cacheOwner.flush(uuid, backend.storageType(), backend.user(uuid)); } public void flushAll() { - requireOpen(); - Set users = Set.copyOf(cacheOwner.cachedUsers()); - for (UUID uuid : users) { - cacheOwner.flush(uuid, backend.user(uuid)); - } + access(() -> { flushAllInternal(); return null; }); } - public synchronized void replaceBackend(SqlUserBackend replacement) { - requireOpen(); - Objects.requireNonNull(replacement, "replacement"); - if (!replacement.isOpen()) { - throw new IllegalArgumentException("replacement backend is closed"); - } - flushAll(); - cacheOwner.clearAfterFlush(); - SqlUserBackend previous = backend; - backend = replacement; - previous.close(); + private void flushAllInternal() { + for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) flushInternal(uuid); } - public void remove(UUID uuid) { - requireOpen(); - Objects.requireNonNull(uuid, "uuid"); - flush(uuid); - backend.user(uuid).delete(backend.storageType()); - cacheOwner.remove(uuid); + public void replaceBackend(SqlUserBackend replacement) { + rejectReentrantTransition(); + access(() -> { + Objects.requireNonNull(replacement, "replacement"); + if (replacement == backend) return null; + if (!replacement.isOpen()) throw new IllegalArgumentException("replacement backend is closed"); + flushAllInternal(); + cacheOwner.clearAfterFlush(); + SqlUserBackend previous = backend; + cacheOwner.bindBackend(replacement); + backend = replacement; + previous.close(); + return null; + }); } - public SqlUserBackend backend() { - return backend; + public void remove(UUID uuid) { + access(() -> { + Objects.requireNonNull(uuid, "uuid"); + flushInternal(uuid); + backend.user(uuid).delete(backend.storageType()); + cacheOwner.remove(uuid); + return null; + }); } - public boolean isClosed() { - return closed; + public SqlUserBackend backend() { return backend; } + public boolean isClosed() { return closed; } + public boolean isRetiring() { return retiring.get(); } + + /** + * Stop admission now and drain on the supplied worker. Completion represents + * actual flush/cleanup, not task submission. A failed attempt retains the + * unflushed queue/provider and can be retried; it never reopens admission. + */ + public CompletionStage closeAsync(Executor executor) { + Objects.requireNonNull(executor, "executor"); + rejectReentrantTransition(); + CompletableFuture result; + synchronized (closeLock) { + if (closeAttempt != null && !closeAttempt.isCompletedExceptionally()) { + return closeAttempt.minimalCompletionStage(); + } + retiring.set(true); + result = new CompletableFuture<>(); + closeAttempt = result; + } + try { + executor.execute(() -> { + try { + cacheOwner.requireBlockingAllowed(); + lifecycle.lock(); + try { + if (!closed) { + flushAllInternal(); + cacheOwner.clearAfterFlush(); + cacheOwner.shutdown(); + backend.close(); + closed = true; + } + } finally { + lifecycle.unlock(); + } + result.complete(null); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + } catch (RuntimeException | Error failure) { + result.completeExceptionally(failure); + } + return result.minimalCompletionStage(); } + /** Blocking worker-only form. Server callbacks must use closeAsync and observe its stage. */ @Override - public synchronized void close() { - if (closed) { - return; + public void close() { + if (closed) return; + cacheOwner.requireBlockingAllowed(); + try { + closeAsync(Runnable::run).toCompletableFuture().join(); + } catch (CompletionException failure) { + if (failure.getCause() instanceof RuntimeException runtime) throw runtime; + if (failure.getCause() instanceof Error error) throw error; + throw failure; + } + } + + private T access(Supplier operation) { + cacheOwner.requireBlockingAllowed(); + boolean admitted = lifecycle.isHeldByCurrentThread(); + if (!admitted) requireOpen(); + lifecycle.lock(); + try { + // Nested notification work belongs to the already-admitted batch. + if (!admitted) requireOpen(); + return operation.get(); + } finally { + lifecycle.unlock(); + } + } + + private void rejectReentrantTransition() { + if (lifecycle.isHeldByCurrentThread()) { + throw new IllegalStateException("Cannot replace or close the user runtime from an active user callback"); } - flushAll(); - cacheOwner.clearAfterFlush(); - cacheOwner.shutdown(); - backend.close(); - closed = true; } private List readStorageRow(UUID uuid) { - SqlUserStorage storage = backend.user(uuid); - return storage.readRow(backend.storageType()); + return backend.user(uuid).readRow(backend.storageType()); } private DataValue find(List row, String key, DataValue defaultValue) { @@ -174,11 +240,7 @@ private DataValue find(List row, String key, DataValue defaultValue) { } private void requireOpen() { - if (closed) { - throw new IllegalStateException("Shared user data runtime is closed"); - } - if (!backend.isOpen()) { - throw new IllegalStateException("SQL user backend is closed"); - } + if (retiring.get()) throw new IllegalStateException("Shared user data runtime is retiring or closed"); + if (!backend.isOpen()) throw new IllegalStateException("SQL user backend is closed"); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/user/runtime/UserCacheOwner.java index 2df60ab8a..289c85813 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 @@ -3,30 +3,35 @@ import java.util.HashMap; import java.util.Set; import java.util.UUID; +import java.util.function.Consumer; +import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.simpleapi.sql.data.DataValue; -/** - * Port implemented by the existing cache owner. The shared runtime deliberately - * does not allocate a second cache or queue. - */ +/** Port for the existing cache/queue owner. No parallel cache is allocated. */ public interface UserCacheOwner { boolean isCached(UUID uuid); - DataValue getIfPresent(UUID uuid, String key); - void populate(UUID uuid, HashMap values); - void queueChange(UUID uuid, String key, DataValue value); - void flush(UUID uuid, SqlUserStorage storage); - Set cachedUsers(); + /** Explicit type avoids inferring the destination from a platform plugin's current settings. */ + default void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { flush(uuid, storage); } - void remove(UUID uuid); + /** Include scheduled cache batches in the runtime admission/drain barrier. */ + default void bindFlushGate(Consumer gate) {} - void clearAfterFlush(); + /** Bind scheduled writes to this provider; invoked before access and after a drained replacement. */ + default void bindBackend(SqlUserBackend backend) {} + + /** Platform adapters reject blocking work on server/entity threads before acquiring any barrier. */ + default void requireBlockingAllowed() {} + Set cachedUsers(); + void remove(UUID uuid); + void clearAfterFlush(); void shutdown(); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java new file mode 100644 index 000000000..efdb85306 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserLifecycleRegressionTest.java @@ -0,0 +1,293 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +/** Memory providers plus real cache/queue code; not live database or game-server integration. */ +@Timeout(15) +class SharedUserLifecycleRegressionTest { + @Test + void closeDrainsAdmittedQueueBeforeTeardownAndRejectsLateWrites() throws Exception { + UUID uuid = UUID.randomUUID(); + UserCacheOwner owner = mock(UserCacheOwner.class); + SqlUserBackend backend = mock(SqlUserBackend.class); + SqlUserStorage storage = mock(SqlUserStorage.class); + when(backend.isOpen()).thenReturn(true); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); + when(backend.user(uuid)).thenReturn(storage); + when(owner.isCached(uuid)).thenReturn(true); + when(owner.cachedUsers()).thenReturn(Set.of(uuid)); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + doAnswer(call -> { entered.countDown(); await(release); return null; }) + .when(owner).queueChange(eq(uuid), anyString(), any()); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future write = workers.submit(() -> runtime.queueChange(uuid, "Points", new DataValueInt(5))); + await(entered); + CompletableFuture closed = runtime.closeAsync(workers).toCompletableFuture(); + assertTrue(runtime.isRetiring()); + assertFalse(closed.isDone()); + assertThrows(IllegalStateException.class, + () -> runtime.queueChange(uuid, "Points", new DataValueInt(9))); + verify(owner, never()).clearAfterFlush(); + release.countDown(); + write.get(5, TimeUnit.SECONDS); + closed.get(5, TimeUnit.SECONDS); + var order = inOrder(owner, backend); + order.verify(owner).flush(uuid, UserStorage.SQLITE, storage); + order.verify(owner).clearAfterFlush(); + order.verify(owner).shutdown(); + order.verify(backend).close(); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void rejectedAsyncCloseKeepsPendingChangesForAnObservedRetry() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(6)); + var result = runtime.closeAsync(task -> { throw new RejectedExecutionException("stopped"); }); + assertThrows(CompletionException.class, () -> result.toCompletableFuture().join()); + assertTrue(runtime.isRetiring()); + assertTrue(fixture.first.isOpen()); + assertTrue(fixture.caches.get(fixture.uuid).hasChangesToProcess()); + runtime.close(); + assertEquals(6, fixture.first.points(fixture.uuid)); + } + + @Test + void failedFlushKeepsQueueAndProviderButDoesNotReopenAdmission() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(6)); + fixture.first.beforeWrite = () -> { throw new IllegalStateException("write failed"); }; + assertThrows(IllegalStateException.class, runtime::close); + assertTrue(fixture.first.isOpen()); + assertFalse(runtime.isClosed()); + assertTrue(fixture.caches.get(fixture.uuid).hasChangesToProcess()); + assertThrows(IllegalStateException.class, + () -> runtime.queueChange(fixture.uuid, "Points", new DataValueInt(9))); + fixture.first.beforeWrite = () -> {}; + runtime.close(); + assertEquals(6, fixture.first.points(fixture.uuid)); + } + + @Test + void explicitAndScheduledFlushesFollowReplacementRatherThanPluginSettings() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(5)); + fixture.tasks.get(0).run(); + assertEquals(5, fixture.first.points(fixture.uuid)); + UserDataCache oldCache = fixture.caches.get(fixture.uuid); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(7)); + MemoryBackend second = new MemoryBackend(UserStorage.MYSQL); + runtime.replaceBackend(second); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertFalse(fixture.first.isOpen()); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(11)); + for (Runnable task : List.copyOf(fixture.tasks)) task.run(); + assertEquals(11, second.points(fixture.uuid)); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertThrows(IllegalStateException.class, + () -> oldCache.addChange(new UserDataChangeInt("Points", 99), true)); + verify(fixture.plugin.getUserManager().getUser(fixture.uuid, false).getUserData(), never()) + .setValues(any(HashMap.class)); + runtime.close(); + } + + @Test + void sameBackendReplacementDoesNotCloseItOrDiscardPendingWrites() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(4)); + runtime.replaceBackend(fixture.first); + assertTrue(fixture.first.isOpen()); + assertTrue(fixture.caches.get(fixture.uuid).hasChangesToProcess()); + runtime.close(); + assertEquals(4, fixture.first.points(fixture.uuid)); + } + + @Test + void scheduledBatchAndNotificationAreInsideTheShutdownBarrier() throws Exception { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + AtomicBoolean firstWrite = new AtomicBoolean(true), firstNotice = new AtomicBoolean(true); + fixture.first.beforeWrite = () -> { + if (firstWrite.getAndSet(false)) { entered.countDown(); await(release); } + }; + doAnswer(call -> { + if (firstNotice.getAndSet(false)) runtime.queueChange(fixture.uuid, "Points", new DataValueInt(8)); + return null; + }).when(fixture.plugin.getUserManager()).onChange(any(AdvancedCoreUser.class), any(String[].class)); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future scheduled = workers.submit(fixture.tasks.get(0)); + await(entered); + CompletableFuture closed = runtime.closeAsync(workers).toCompletableFuture(); + assertFalse(closed.isDone()); + release.countDown(); + scheduled.get(5, TimeUnit.SECONDS); + closed.get(5, TimeUnit.SECONDS); + assertEquals(8, fixture.first.points(fixture.uuid)); + assertTrue(runtime.isClosed()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void blockingCloseIsRejectedOnServerThreadButAsyncCloseDoesNotWaitThere() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + List workerQueue = new ArrayList<>(); + CompletionStage closed; + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertThrows(IllegalStateException.class, runtime::close); + assertFalse(runtime.isRetiring()); + closed = runtime.closeAsync(workerQueue::add); + assertFalse(closed.toCompletableFuture().isDone()); + assertEquals(1, workerQueue.size()); + } + workerQueue.get(0).run(); + closed.toCompletableFuture().join(); + assertTrue(runtime.isClosed()); + } + + @Test + void directWritesToTheBoundCacheAlsoRejectRetirement() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + List workers = new ArrayList<>(); + var closed = runtime.closeAsync(workers::add).toCompletableFuture(); + assertThrows(IllegalStateException.class, + () -> cache.addChange(new UserDataChangeInt("Points", 9), true)); + workers.get(0).run(); + closed.join(); + assertEquals(3, fixture.first.points(fixture.uuid)); + } + + @Test + void shutdownCancelsDelayedTimerWorkWithoutAwaitingIt() throws Exception { + Fixture fixture = new Fixture(); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(fixture.manager.getTimer()).thenReturn(timer); + try { + ScheduledFuture delayed = timer.schedule((Runnable) () -> fail("redundant delayed task ran"), 1, TimeUnit.HOURS); + fixture.owner.shutdown(); + assertFalse(timer.getExecuteExistingDelayedTasksAfterShutdownPolicy()); + assertTrue(delayed.isCancelled()); + assertTrue(timer.awaitTermination(2, TimeUnit.SECONDS)); + } finally { timer.shutdownNow(); } + } + + private static void await(CountDownLatch latch) { + try { assertTrue(latch.await(5, TimeUnit.SECONDS)); } + catch (InterruptedException failure) { Thread.currentThread().interrupt(); throw new IllegalStateException(failure); } + } + + private static final class Fixture { + final UUID uuid = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + final UserDataManager manager = mock(UserDataManager.class); + final ConcurrentHashMap caches = new ConcurrentHashMap<>(); + final List tasks = new CopyOnWriteArrayList<>(); + final MemoryBackend first = new MemoryBackend(UserStorage.SQLITE); + final BukkitUserCacheOwner owner; + Fixture() { + when(manager.getPlugin()).thenReturn(plugin); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(manager.getUserDataCache()).thenReturn(caches); + when(manager.isCached(any(UUID.class))).thenAnswer(call -> { + UserDataCache cache = caches.get(call.getArgument(0)); + return cache != null && cache.hasCache(); + }); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(manager.getTimer()).thenReturn(timer); + when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return mock(ScheduledFuture.class); + }); + first.rows.put(uuid, new HashMap<>(Map.of("Points", new DataValueInt(1)))); + owner = new BukkitUserCacheOwner(manager); + } + SharedUserDataRuntime runtime() { return new SharedUserDataRuntime(first, owner); } + } + + private static final class MemoryBackend implements SqlUserBackend { + final UserStorage type; + final Map> rows = new ConcurrentHashMap<>(); + volatile boolean open = true; + volatile Runnable beforeWrite = () -> {}; + MemoryBackend(UserStorage type) { this.type = type; } + int points(UUID uuid) { return rows.get(uuid).get("Points").getInt(); } + public UserStorage storageType() { return type; } + public boolean isOpen() { return open; } + public void close() { open = false; } + public List enumerateUsers() { return new ArrayList<>(rows.keySet()); } + public SqlUserStorage user(UUID uuid) { + if (!open) throw new IllegalStateException("closed"); + return new SqlUserStorage() { + public List readRow(UserStorage requested) { + List result = new ArrayList<>(); + rows.getOrDefault(uuid, new HashMap<>()).forEach((key, value) -> result.add(new Column(key, value))); + return result; + } + public boolean contains(UserStorage requested) { return rows.containsKey(uuid); } + public void delete(UserStorage requested) { rows.remove(uuid); } + public void write(UserStorage requested, String key, DataValue value) { + writeValues(requested, new HashMap<>(Map.of(key, value))); + } + public void writeValues(UserStorage requested, HashMap values) { + assertEquals(type, requested); + if (!open) throw new IllegalStateException("closed"); + beforeWrite.run(); + rows.computeIfAbsent(uuid, ignored -> new HashMap<>()).putAll(values); + } + }; + } + } +} From 6cdb6703331ff498c867a68ac8f3f1bde13b6808 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 12 Sep 2026 23:22:51 -0600 Subject: [PATCH 06/34] Fix nested Mockito stubbing in shutdown barrier regression Resolve the existing UserManager mock before starting doAnswer stubbing. The prior Actions run compiled production and tests but failed this test fixture with UnfinishedStubbingException; all assertions remain unchanged. Local Java21 syntax and incremental whitespace checks passed. Full test execution remains through GitHub Actions, not a local Maven build. --- .../tests/user/SharedUserLifecycleRegressionTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 efdb85306..90f4b688c 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 @@ -152,10 +152,11 @@ void scheduledBatchAndNotificationAreInsideTheShutdownBarrier() throws Exception fixture.first.beforeWrite = () -> { if (firstWrite.getAndSet(false)) { entered.countDown(); await(release); } }; + var userManager = fixture.plugin.getUserManager(); doAnswer(call -> { if (firstNotice.getAndSet(false)) runtime.queueChange(fixture.uuid, "Points", new DataValueInt(8)); return null; - }).when(fixture.plugin.getUserManager()).onChange(any(AdvancedCoreUser.class), any(String[].class)); + }).when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); ExecutorService workers = Executors.newFixedThreadPool(2); try { From 5442c3777a598f38f1599591bd52d473576dc772 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:07:38 -0600 Subject: [PATCH 07/34] Fix late cache ownership, cache-only reads and atomic attachment Register one manager-level initializer so caches created through existing getCache/cacheUser paths or inserted later acquire the selected writer and runtime admission gate before mutation/flush. Preserve existing queues, notifications and legacy virtual cache-loading hooks. Refuse monitor-order inversions and retain failed batches for retry. Publish Bukkit owner lifecycle state atomically without eagerly taking over active legacy batches. Attachment failure leaves the owner reusable and an active legacy batch leaves a reachable runtime with retryable flush/close. Honor allowStorageLookup before populating caches for no-database modes. Add 14 focused headless regressions with the real manager/cache and in-memory providers plus mocked platform boundaries. Local Java 21 syntax and incremental whitespace checks passed; full local Maven and live-server checks remain unavailable. GitHub Actions must validate this candidate. --- .../api/user/usercache/UserDataCache.java | 12 + .../api/user/usercache/UserDataManager.java | 25 ++ .../user/runtime/BukkitUserCacheOwner.java | 35 +- .../user/runtime/SharedUserDataRuntime.java | 5 +- .../core/user/runtime/UserCacheOwner.java | 6 + .../SharedCacheBindingRegressionTest.java | 307 ++++++++++++++++++ 6 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java 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 a38cc6423..1102b3727 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 @@ -40,7 +40,17 @@ public UserDataCache(UserDataManager manager, UUID uuid) { cache = new HashMap<>(); } + private void initializeSharedStorage() { + synchronized (this) { + // Do not rebind a retired generation or replace an established owner. + if (sharedFlushGate != null || uuid == null || cachedChanges == null) return; + } + // The initializer may take the runtime gate: never call it under this monitor. + if (manager != null) manager.initializeSharedCache(this); + } + public void addChange(UserDataChange change, boolean queue) { + initializeSharedStorage(); Consumer gate; synchronized (this) { gate = sharedFlushGate; @@ -105,6 +115,7 @@ public synchronized UserDataCache cache() { } public void clearCache() { + initializeSharedStorage(); Consumer gate; synchronized (this) { gate = sharedFlushGate; @@ -218,6 +229,7 @@ public synchronized void retireAfterSharedFlush() { } public void processChanges() { + initializeSharedStorage(); processChangesInternal(false); } 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 6abd7838e..83802d9f7 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 @@ -2,6 +2,8 @@ import java.util.ArrayList; import java.util.UUID; +import java.util.Objects; +import java.util.function.Consumer; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -40,6 +42,29 @@ public class UserDataManager { @Getter private ConcurrentHashMap userDataCache; + private volatile Consumer sharedCacheInitializer; + + /** One shared owner for the existing map, including caches created by legacy callers. */ + public final synchronized void bindSharedCacheInitializer(Consumer initializer) { + Objects.requireNonNull(initializer, "initializer"); + if (sharedCacheInitializer != null && sharedCacheInitializer != initializer) { + throw new IllegalStateException("User data manager already belongs to another shared runtime"); + } + sharedCacheInitializer = initializer; + } + + void initializeSharedCache(UserDataCache cache) { + Consumer initializer = sharedCacheInitializer; + if (initializer != null) { + // An old synchronized caller can race the initial attachment. Refuse + // that takeover rather than invert the runtime -> cache lock order. + if (Thread.holdsLock(cache)) { + throw new IllegalStateException("Cannot attach shared storage while holding the cache monitor"); + } + initializer.accept(cache); + } + } + public UserDataManager(AdvancedCorePlugin plugin) { this.plugin = plugin; userDataCache = new ConcurrentHashMap<>(); 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 8b9e16af4..713095d96 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 @@ -26,10 +26,17 @@ public final class BukkitUserCacheOwner implements UserCacheOwner { private final UserDataManager manager; private volatile SqlUserBackend backend; - private Consumer flushGate; + private volatile Consumer flushGate; + private final Consumer cacheInitializer; public BukkitUserCacheOwner(UserDataManager manager) { this.manager = Objects.requireNonNull(manager, "manager"); + cacheInitializer = cache -> { + Consumer gate; + synchronized (this) { gate = flushGate; } + if (gate == null) bind(cache, cache.getUuid()); + else gate.accept(() -> bind(cache, cache.getUuid())); + }; } @Override @@ -42,9 +49,28 @@ public synchronized void bindFlushGate(Consumer gate) { } @Override - public void bindBackend(SqlUserBackend backend) { - this.backend = Objects.requireNonNull(backend, "backend"); - manager.getUserDataCache().forEach((uuid, cache) -> bind(cache, uuid)); + public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { + Objects.requireNonNull(backend, "backend"); + Objects.requireNonNull(gate, "gate"); + if (flushGate != null && flushGate != gate) { + throw new IllegalStateException("Cache owner already belongs to another runtime"); + } + // Installation publishes no cache changes and runs no callbacks. A failed + // registration leaves this owner reusable; the callback takes this monitor + // before using the gate, so it cannot observe a half-published binding. + manager.bindSharedCacheInitializer(cacheInitializer); + this.backend = backend; + flushGate = gate; + } + + @Override + public synchronized void bindBackend(SqlUserBackend backend) { + Objects.requireNonNull(backend, "backend"); + manager.bindSharedCacheInitializer(cacheInitializer); + this.backend = backend; + // Existing legacy batches finish with their original writer. Binding is + // lazy and refuses an active legacy batch, leaving a reachable runtime + // whose flush/close can be retried instead of orphaning half-bound caches. } private void bind(UserDataCache cache, UUID uuid) { @@ -102,6 +128,7 @@ public void flush(UUID uuid, SqlUserStorage storage) { public void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache != null) { + bind(cache, uuid); cache.setSharedStorageWriter(values -> storage.writeValues(type, values)); do { // Includes the existing in-flight batch and its notifications before returning. 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 582c95c59..bcf9752c4 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 @@ -33,8 +33,7 @@ public final class SharedUserDataRuntime implements AutoCloseable { public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) { this.backend = Objects.requireNonNull(backend, "backend"); this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); - cacheOwner.bindFlushGate(batch -> access(() -> { batch.run(); return null; })); - cacheOwner.bindBackend(backend); + cacheOwner.bindLifecycle(backend, batch -> access(() -> { batch.run(); return null; })); } public DataValue read(UUID uuid, String key, UserDataFetchMode mode, @@ -51,7 +50,7 @@ public DataValue read(UUID uuid, String key, UserDataFetchMode mode, if (mode.allowUserCache()) { DataValue cached = cacheOwner.getIfPresent(uuid, key); if (cached != null) return cached; - if (mode.waitForCache() && !cacheOwner.isCached(uuid)) { + if (mode.allowStorageLookup() && mode.waitForCache() && !cacheOwner.isCached(uuid)) { populateInternal(uuid); cached = cacheOwner.getIfPresent(uuid, key); if (cached != null) return cached; 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 289c85813..d1f6d4a89 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 @@ -27,6 +27,12 @@ default void bindFlushGate(Consumer gate) {} /** Bind scheduled writes to this provider; invoked before access and after a drained replacement. */ default void bindBackend(SqlUserBackend backend) {} + /** Bind initial lifecycle state together. Stateful adapters override for atomic publication. */ + default void bindLifecycle(SqlUserBackend backend, Consumer gate) { + bindFlushGate(gate); + bindBackend(backend); + } + /** Platform adapters reject blocking work on server/entity threads before acquiring any barrier. */ default void requireBlockingAllowed() {} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java new file mode 100644 index 000000000..62ea07a42 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java @@ -0,0 +1,307 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +/** Real manager/cache/queue with in-memory providers; no live database or game server. */ +@Timeout(15) +class SharedCacheBindingRegressionTest { + @Test void noDatabaseLookupMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.NO_DB_LOOKUP); } + @Test void cacheOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.CACHE_ONLY); } + @Test void temporaryOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.TEMP_ONLY); } + + private void cacheMiss(UserDataFetchMode mode) { + SqlUserBackend backend = mock(SqlUserBackend.class); + UserCacheOwner owner = mock(UserCacheOwner.class); + when(backend.isOpen()).thenReturn(true); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + UUID uuid = UUID.randomUUID(); + DataValue fallback = new DataValueInt(17); + assertSame(fallback, runtime.read(uuid, "Points", mode, null, fallback)); + assertSame(fallback, runtime.read(uuid, "Points", mode, new HashMap<>(), fallback)); + verify(backend, never()).user(any(UUID.class)); + verify(backend, never()).enumerateUsers(); + verify(owner, never()).populate(any(UUID.class), any()); + verify(owner, never()).queueChange(any(), any(), any()); + verify(owner, never()).flush(any(), any(UserStorage.class), any()); + } + + @Test void noDatabaseModesRetainTemporaryAndUserCachePrecedence() { + SqlUserBackend backend = mock(SqlUserBackend.class); + UserCacheOwner owner = mock(UserCacheOwner.class); + when(backend.isOpen()).thenReturn(true); + UUID uuid = UUID.randomUUID(); + DataValue cached = new DataValueInt(2), temporary = new DataValueInt(3); + when(owner.getIfPresent(uuid, "Points")).thenReturn(cached); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, owner); + HashMap temp = new HashMap<>(Map.of("Points", temporary)); + assertSame(temporary, runtime.read(uuid, "Points", UserDataFetchMode.NO_DB_LOOKUP, temp, null)); + assertSame(cached, runtime.read(uuid, "Points", UserDataFetchMode.CACHE_ONLY, temp, null)); + assertSame(temporary, runtime.read(uuid, "Points", UserDataFetchMode.TEMP_ONLY, temp, null)); + verify(backend, never()).user(any(UUID.class)); + verify(owner, never()).populate(any(), any()); + } + + @Test void managerGetCacheAfterRuntimeStartupUsesTheSelectedWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 7), true); + fixture.tasks.get(0).run(); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertFalse(cache.hasChangesToProcess()); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void joinStyleCacheCreationAfterStartupUsesTheSelectedWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.cacheUser(fixture.uuid, null); + fixture.manager.getCache(fixture.uuid).addChange(new UserDataChangeInt("Points", 8), true); + fixture.tasks.get(0).run(); + assertEquals(8, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void directCacheInsertionAfterStartupAlsoGetsTheGateAndWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + cache.addChange(new UserDataChangeInt("Points", 9), true); + cache.processChanges(); + assertEquals(9, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void aWriterWithoutALifecycleGateDoesNotPreventSharedAttachment() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + cache.setSharedStorageWriter(values -> fail("ungated writer was reused")); + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + cache.addChange(new UserDataChangeInt("Points", 16), true); + runtime.close(); + assertEquals(16, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + } + } + + @Test void anAlreadyConstructedCacheCannotBypassBindingWhenInsertedLater() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 10), true); + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + fixture.tasks.get(0).run(); + assertEquals(10, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void cachesCreatedAfterReplacementDoNotReuseTheOldWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache old = fixture.manager.getCache(fixture.uuid); + old.addChange(new UserDataChangeInt("Points", 11), true); + MemoryBackend replacement = new MemoryBackend(UserStorage.SQLITE); + runtime.replaceBackend(replacement); + assertEquals(11, fixture.first.points(fixture.uuid)); + UserDataCache fresh = fixture.manager.getCache(fixture.uuid); + assertNotSame(old, fresh); + fresh.addChange(new UserDataChangeInt("Points", 12), true); + fresh.processChanges(); + assertEquals(12, replacement.points(fixture.uuid)); + assertEquals(11, fixture.first.points(fixture.uuid)); + assertThrows(IllegalStateException.class, + () -> old.addChange(new UserDataChangeInt("Points", 99), true)); + fixture.assertNoLegacyWrites(); + runtime.close(); + } + } + + @Test void newlyInsertedCacheCannotQueueAfterRetirementStarts() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + List worker = new ArrayList<>(); + CompletableFuture closing = runtime.closeAsync(worker::add).toCompletableFuture(); + UserDataCache late = new UserDataCache(fixture.manager, fixture.uuid); + fixture.manager.getUserDataCache().put(fixture.uuid, late); + assertThrows(IllegalStateException.class, + () -> late.addChange(new UserDataChangeInt("Points", 99), true)); + assertFalse(late.hasChangesToProcess()); + worker.get(0).run(); + closing.join(); + assertThrows(IllegalStateException.class, + () -> late.addChange(new UserDataChangeInt("Points", 99), true)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + } + } + + @Test void legacyMonitorHeldDuringLazyAttachmentFailsWithoutDeadlocking() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = new UserDataCache(fixture.manager, fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 15), true); + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getUserDataCache().put(fixture.uuid, cache); + synchronized (cache) { + assertThrows(IllegalStateException.class, cache::processChanges); + } + assertTrue(cache.hasChangesToProcess()); + runtime.close(); + assertEquals(15, fixture.first.points(fixture.uuid)); + } + } + + @Test void aFailedAttachmentLeavesTheSameOwnerReusable() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException unavailable = new IllegalStateException("registration unavailable"); + doThrow(unavailable).doCallRealMethod().when(fixture.manager).bindSharedCacheInitializer(any()); + assertSame(unavailable, assertThrows(IllegalStateException.class, fixture::runtime)); + SharedUserDataRuntime retry = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 13), true); + retry.close(); + assertEquals(13, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); + } + } + + @Test void attachmentDuringALegacyBatchKeepsAReachableRetryableRuntime() throws Exception { + try (Fixture fixture = new Fixture()) { + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 1), true); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + var legacyData = cache.getUser().getUserData(); + doAnswer(call -> { entered.countDown(); await(release); return null; }) + .when(legacyData).setValues(any(HashMap.class)); + var worker = Executors.newSingleThreadExecutor(); + try { + var legacy = worker.submit(cache::processChanges); + await(entered); + SharedUserDataRuntime runtime = fixture.runtime(); + assertThrows(IllegalStateException.class, () -> runtime.flush(fixture.uuid)); + assertFalse(runtime.isClosed()); + release.countDown(); + legacy.get(5, TimeUnit.SECONDS); + cache.addChange(new UserDataChangeInt("Points", 14), true); + runtime.close(); + assertEquals(14, fixture.first.points(fixture.uuid)); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + } + + private static void await(CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(5, TimeUnit.SECONDS), "batch did not reach the expected boundary"); + } + + private static final class Fixture implements AutoCloseable { + final UUID uuid = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + final UserDataManager manager; + final BukkitUserCacheOwner owner; + final MemoryBackend first = new MemoryBackend(UserStorage.MYSQL); + final List tasks = new CopyOnWriteArrayList<>(); + + Fixture() throws Exception { + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + manager = spy(new UserDataManager(plugin)); + manager.getTimer().shutdownNow(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + Field field = UserDataManager.class.getDeclaredField("timer"); + field.setAccessible(true); + field.set(manager, timer); + when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return mock(ScheduledFuture.class); + }); + var data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenAnswer(ignored -> new ArrayList()); + when(data.getValues()).thenAnswer(ignored -> new HashMap()); + owner = new BukkitUserCacheOwner(manager); + } + + SharedUserDataRuntime runtime() { return new SharedUserDataRuntime(first, owner); } + void assertNoLegacyWrites() { + verify(plugin.getUserManager().getUser(uuid, false).getUserData(), never()).setValues(any(HashMap.class)); + } + public void close() { manager.getTimer().shutdownNow(); } + } + + private static final class MemoryBackend implements SqlUserBackend { + final UserStorage type; + final Map> rows = new ConcurrentHashMap<>(); + boolean open = true; + MemoryBackend(UserStorage type) { this.type = type; } + int points(UUID uuid) { return rows.get(uuid).get("Points").getInt(); } + public UserStorage storageType() { return type; } + public boolean isOpen() { return open; } + public void close() { open = false; } + public List enumerateUsers() { return new ArrayList<>(rows.keySet()); } + public SqlUserStorage user(UUID uuid) { + if (!open) throw new IllegalStateException("closed backend"); + return new SqlUserStorage() { + public List readRow(UserStorage requested) { + List columns = new ArrayList<>(); + rows.getOrDefault(uuid, new HashMap<>()).forEach((key, value) -> columns.add(new Column(key, value))); + return columns; + } + public boolean contains(UserStorage requested) { return rows.containsKey(uuid); } + public void delete(UserStorage requested) { rows.remove(uuid); } + public void write(UserStorage requested, String key, DataValue value) { + writeValues(requested, new HashMap<>(Map.of(key, value))); + } + public void writeValues(UserStorage requested, HashMap values) { + assertEquals(type, requested); + if (!open) throw new IllegalStateException("closed backend"); + rows.computeIfAbsent(uuid, ignored -> new HashMap<>()).putAll(values); + } + }; + } + } +} From f0fba334c9af3d60b30f9212f90c74ddfb8710db Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:41:40 -0600 Subject: [PATCH 08/34] Route legacy user data through shared backend safely --- .../api/user/usercache/UserDataCache.java | 243 +++++------------- .../api/user/usercache/UserDataManager.java | 188 ++++++-------- .../user/runtime/BukkitUserCacheOwner.java | 29 +-- .../user/storage/BukkitSqlUserStorage.java | 55 ++-- .../user/SharedLegacyBackendRoutingTest.java | 58 +++++ .../user/UserDataCachePopulationRaceTest.java | 38 +++ 6 files changed, 267 insertions(+), 344 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java 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 1102b3727..e1b0ddb3d 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 @@ -19,19 +19,15 @@ import lombok.Getter; public class UserDataCache { - @Getter - private HashMap cache; - + @Getter private HashMap cache; private Queue cachedChanges; - private final UserDataManager manager; private boolean scheduled = false; private int inFlightBatches = 0; private volatile Consumer> sharedStorageWriter; private Thread sharedBatchThread; private volatile Consumer sharedFlushGate; - @Getter - private UUID uuid; + @Getter private UUID uuid; public UserDataCache(UserDataManager manager, UUID uuid) { this.uuid = uuid; @@ -41,11 +37,7 @@ public UserDataCache(UserDataManager manager, UUID uuid) { } private void initializeSharedStorage() { - synchronized (this) { - // Do not rebind a retired generation or replace an established owner. - if (sharedFlushGate != null || uuid == null || cachedChanges == null) return; - } - // The initializer may take the runtime gate: never call it under this monitor. + synchronized (this) { if (sharedFlushGate != null || uuid == null || cachedChanges == null) return; } if (manager != null) manager.initializeSharedCache(this); } @@ -54,10 +46,7 @@ public void addChange(UserDataChange change, boolean queue) { Consumer gate; synchronized (this) { gate = sharedFlushGate; - if (gate == null) { - addChangeInternal(change, queue); - return; - } + if (gate == null) { addChangeInternal(change, queue); return; } } gate.accept(() -> addChangeInternal(change, queue)); } @@ -66,15 +55,11 @@ 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"); } - if (change == null || cache == null || cachedChanges == null) { - return; - } + if (change == null || cache == null || cachedChanges == null) return; cache.put(change.getKey(), change.toUserDataValue()); if (queue) { cachedChanges.add(change); - if (!scheduled) { - scheduleChanges(); - } + if (!scheduled) scheduleChanges(); } } @@ -89,27 +74,17 @@ public synchronized UserDataCache cache() { keys.remove(key); if (data.containsKey(key)) { DataValue dataValue = data.get(key); - manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " - + uuid.toString() + ", value: " + dataValue.toString()); - try { - if (cache.containsKey(key) && !cache.get(key).toString().equals(dataValue.toString())) { - changedKeys.add(key); - } - } catch (Exception e) { - manager.getPlugin().debug(e); - } + manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " + uuid + ", value: " + dataValue); + try { if (cache.containsKey(key) && !cache.get(key).toString().equals(dataValue.toString())) changedKeys.add(key); } + catch (Exception e) { manager.getPlugin().debug(e); } cache.put(key, dataValue); } else { - manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + uuid.toString()); + manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + uuid); cache.put(key, dataKey.getDefault()); } } - if (!changedKeys.isEmpty()) { - manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); - } - if (keys.size() > 0) { - manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); - } + if (!changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); + if (!keys.isEmpty()) manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); } return this; } @@ -125,35 +100,19 @@ public void clearCache() { return; } } - // Acquire the runtime gate BEFORE the cache monitor, including legacy cleanup callers. - gate.accept(() -> { - processChanges(); - synchronized (this) { if (cache != null) cache.clear(); } - }); - } - - public void clearChanges() { - if (hasChangesToProcess()) processChanges(); - } - - public void displayCache() { - manager.getPlugin().devDebug(displayCacheStringList().toString()); + gate.accept(() -> { processChanges(); synchronized (this) { if (cache != null) cache.clear(); } }); } + public void clearChanges() { if (hasChangesToProcess()) processChanges(); } + public void displayCache() { manager.getPlugin().devDebug(displayCacheStringList().toString()); } public synchronized ArrayList displayCacheStringList() { ArrayList list = new ArrayList<>(); list.add("Current cache for " + uuid + ": "); - if (cache == null) { - return list; - } - for (Entry entry : getCache().entrySet()) { - if (entry.getValue().isBoolean()) { - list.add(entry.getKey() + "=" + entry.getValue().getBoolean()); - } else if (entry.getValue().isString()) { - list.add(entry.getKey() + "=" + entry.getValue().getString()); - } else if (entry.getValue().isInt()) { - list.add(entry.getKey() + "=" + entry.getValue().getInt()); - } + if (cache == null) return list; + for (Entry entry : cache.entrySet()) { + if (entry.getValue().isBoolean()) list.add(entry.getKey() + "=" + entry.getValue().getBoolean()); + else if (entry.getValue().isString()) list.add(entry.getKey() + "=" + entry.getValue().getString()); + else if (entry.getValue().isInt()) list.add(entry.getKey() + "=" + entry.getValue().getInt()); } return list; } @@ -163,75 +122,37 @@ public void dump() { processChanges(); synchronized (this) { while (inFlightBatches > 0) { - try { - wait(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - } - if (cachedChanges != null && !cachedChanges.isEmpty()) { - continue; + try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } - cache = null; - cachedChanges = null; - uuid = null; - scheduled = false; - return; + if (cachedChanges != null && !cachedChanges.isEmpty()) continue; + cache = null; cachedChanges = null; uuid = null; scheduled = false; return; } } } - public AdvancedCoreUser getUser() { - return manager.getPlugin().getUserManager().getUser(uuid, false); - } - - public synchronized boolean hasCache() { - return cache != null && !cache.isEmpty(); - } - - public synchronized boolean hasChangesToProcess() { - return cachedChanges != null && !cachedChanges.isEmpty(); - } - - public synchronized boolean isCached(String key) { - return cache != null && cache.containsKey(key); - } + public AdvancedCoreUser getUser() { return manager.getPlugin().getUserManager().getUser(uuid, false); } + public synchronized boolean hasCache() { return cache != null && !cache.isEmpty(); } + public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } + public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } - /** Install the gate and destination together; never take over an active legacy batch. */ - public synchronized void configureSharedStorage(Consumer> writer, - Consumer gate) { - if (sharedFlushGate != null && sharedFlushGate != gate) { - throw new IllegalStateException("Shared user cache already belongs to another runtime"); - } - if (sharedFlushGate == null && gate != null && inFlightBatches != 0) { - throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); - } + public synchronized void configureSharedStorage(Consumer> writer, Consumer gate) { + if (sharedFlushGate != null && sharedFlushGate != gate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); + if (sharedFlushGate == null && gate != null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); setSharedStorageWriter(writer); sharedFlushGate = gate; } - /** Bind shared storage without replacing the queue or legacy notification hooks. */ public synchronized void setSharedStorageWriter(Consumer> writer) { if (uuid == null || cachedChanges == null) throw new IllegalStateException("Shared user cache is retired"); sharedStorageWriter = java.util.Objects.requireNonNull(writer, "writer"); } - /** Retire only after a successful drain; a racing write fails rather than disappearing. */ public synchronized void retireAfterSharedFlush() { - if (inFlightBatches != 0 || (cachedChanges != null && !cachedChanges.isEmpty())) { - throw new IllegalStateException("Shared user cache has unflushed work"); - } - cache = null; - cachedChanges = null; - uuid = null; - scheduled = false; + if (inFlightBatches != 0 || (cachedChanges != null && !cachedChanges.isEmpty())) throw new IllegalStateException("Shared user cache has unflushed work"); + cache = null; cachedChanges = null; uuid = null; scheduled = false; } - public void processChanges() { - initializeSharedStorage(); - processChangesInternal(false); - } + public void processChanges() { initializeSharedStorage(); processChangesInternal(false); } private void processChangesInternal(boolean admitted) { UUID currentUuid = null; @@ -243,119 +164,79 @@ private void processChangesInternal(boolean admitted) { if (gate == null) { writer = sharedStorageWriter; if (writer != null) { - // Scheduled and explicit shared flushes serialize the SAME queue, including - // an earlier batch whose values have already been drained from it. - if (sharedBatchThread == Thread.currentThread()) { - throw new IllegalStateException("Cannot flush a shared cache from its own change notification"); - } + 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); - } + 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; - } + if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) return; UserDataChange change; - while ((change = cachedChanges.poll()) != null) { - changes.add(change); - } + while ((change = cachedChanges.poll()) != null) changes.add(change); inFlightBatches++; if (writer != null) sharedBatchThread = Thread.currentThread(); } } - if (gate != null) { - gate.accept(() -> processChangesInternal(true)); - return; - } - + if (gate != null) { gate.accept(() -> processChangesInternal(true)); return; } boolean persisted = false; try { manager.getPlugin().extraDebug("Processing changes for " + currentUuid + ", Changes: " + changes.size()); AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); HashMap values = new HashMap<>(); ArrayList keys = new ArrayList<>(); - for (UserDataChange change : changes) { - values.put(change.getKey(), change.toUserDataValue()); - keys.add(change.getKey()); - } - if (!values.isEmpty()) { - if (writer == null) user.getUserData().setValues(values); - else writer.accept(values); - } + for (UserDataChange change : changes) { values.put(change.getKey(), change.toUserDataValue()); keys.add(change.getKey()); } + if (!values.isEmpty()) { if (writer == null) user.getUserData().setValues(values); else writer.accept(values); } persisted = true; manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(keys)); - for (UserDataChange change : changes) { - change.dump(); - } + for (UserDataChange change : changes) change.dump(); } catch (RuntimeException | Error e) { - if (!persisted) { - requeueChanges(changes); - } + if (!persisted) requeueChanges(changes); throw e; - } finally { - finishInFlightBatch(); - } + } finally { finishInFlightBatch(); } } private synchronized void requeueChanges(ArrayList changes) { - if (changes == null || changes.isEmpty() || cachedChanges == null) { - return; - } + if (changes == null || changes.isEmpty() || cachedChanges == null) return; Queue restored = new ConcurrentLinkedQueue<>(); - restored.addAll(changes); - restored.addAll(cachedChanges); - cachedChanges = restored; + restored.addAll(changes); restored.addAll(cachedChanges); cachedChanges = restored; } private synchronized void finishInFlightBatch() { - if (inFlightBatches > 0) { - inFlightBatches--; - if (sharedBatchThread == Thread.currentThread()) sharedBatchThread = null; - } + if (inFlightBatches > 0) { inFlightBatches--; if (sharedBatchThread == Thread.currentThread()) sharedBatchThread = null; } notifyAll(); } - public void processChangesAsync() { - if (uuid != null && hasChangesToProcess()) { - manager.getPlugin().getTimer().execute(this::processChanges); - } - } + public void processChangesAsync() { if (uuid != null && hasChangesToProcess()) manager.getPlugin().getTimer().execute(this::processChanges); } private synchronized void scheduleChanges() { - if (scheduled || cachedChanges == null || cachedChanges.isEmpty()) { - return; - } + if (scheduled || cachedChanges == null || cachedChanges.isEmpty()) return; manager.getPlugin().debug("Schedule changes"); scheduled = true; try { manager.getTimer().schedule(() -> { - try { - processChanges(); - } catch (Exception e) { - manager.getPlugin().debug(e); - } finally { - onScheduledFlushComplete(); - } + try { processChanges(); } catch (Exception e) { manager.getPlugin().debug(e); } + finally { onScheduledFlushComplete(); } }, 3, TimeUnit.SECONDS); - } catch (RejectedExecutionException e) { - scheduled = false; - manager.getPlugin().debug(e); - } + } catch (RejectedExecutionException e) { scheduled = false; manager.getPlugin().debug(e); } } private synchronized void onScheduledFlushComplete() { scheduled = false; - if (cachedChanges != null && !cachedChanges.isEmpty()) { - scheduleChanges(); - } + if (cachedChanges != null && !cachedChanges.isEmpty()) scheduleChanges(); } public synchronized void updateCache(HashMap tempCache) { cache = tempCache == null ? new HashMap<>() : new HashMap<>(tempCache); } + + /** Merge a storage refresh without overwriting values that are queued or currently being persisted. */ + public synchronized void updateCachePreservingPending(HashMap storageValues) { + HashMap refreshed = storageValues == null ? new HashMap<>() : new HashMap<>(storageValues); + if (inFlightBatches > 0 && cache != null) refreshed.putAll(cache); + if (cachedChanges != null) { + for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); + } + cache = refreshed; + } } 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 83802d9f7..37a966f08 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java @@ -1,13 +1,15 @@ package com.bencodez.advancedcore.api.user.usercache; import java.util.ArrayList; -import java.util.UUID; import java.util.Objects; -import java.util.function.Consumer; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiFunction; +import java.util.function.Consumer; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -19,30 +21,23 @@ import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyBoolean; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyInt; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyString; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.simpleapi.debug.DebugLevel; import lombok.Getter; public class UserDataManager { - @Getter - private ArrayList keys; - - @Getter - private ArrayList intColumns; - - @Getter - private ArrayList booleanColumns; - - @Getter - private AdvancedCorePlugin plugin; - - @Getter - private ScheduledExecutorService timer; - - @Getter - private ConcurrentHashMap userDataCache; + @Getter private ArrayList keys; + @Getter private ArrayList intColumns; + @Getter private ArrayList booleanColumns; + @Getter private AdvancedCorePlugin plugin; + @Getter private ScheduledExecutorService timer; + @Getter private ConcurrentHashMap userDataCache; private volatile Consumer sharedCacheInitializer; + private volatile SqlUserBackend sharedSqlBackend; + private volatile Consumer sharedSqlGate; /** One shared owner for the existing map, including caches created by legacy callers. */ public final synchronized void bindSharedCacheInitializer(Consumer initializer) { @@ -56,8 +51,6 @@ public final synchronized void bindSharedCacheInitializer(Consumer initializer = sharedCacheInitializer; if (initializer != null) { - // An old synchronized caller can race the initial attachment. Refuse - // that takeover rather than invert the runtime -> cache lock order. if (Thread.holdsLock(cache)) { throw new IllegalStateException("Cannot attach shared storage while holding the cache monitor"); } @@ -65,6 +58,44 @@ void initializeSharedCache(UserDataCache cache) { } } + /** Publish the backend and the runtime admission gate as one legacy-facade route. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Consumer gate) { + sharedSqlBackend = Objects.requireNonNull(backend, "backend"); + sharedSqlGate = Objects.requireNonNull(gate, "gate"); + } + + public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { + if (sharedSqlBackend == expected) { + sharedSqlBackend = null; + sharedSqlGate = null; + } + } + + public final boolean hasSharedSqlBackend() { + return sharedSqlBackend != null && sharedSqlGate != null; + } + + /** + * Route legacy UserData/BukkitSqlUserStorage access through the currently selected + * shared backend while holding the same lifecycle admission used by runtime calls. + */ + public final T withSharedSqlBackend(UUID uuid, + BiFunction operation) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(operation, "operation"); + Consumer gate = sharedSqlGate; + if (gate == null) throw new IllegalStateException("Shared SQL backend is not bound"); + AtomicReference result = new AtomicReference<>(); + gate.accept(() -> { + SqlUserBackend selected = sharedSqlBackend; + if (selected == null || !selected.isOpen()) { + throw new IllegalStateException("Shared SQL backend is unavailable"); + } + result.set(operation.apply(selected.storageType(), selected.user(uuid))); + }); + return result.get(); + } + public UserDataManager(AdvancedCorePlugin plugin) { this.plugin = plugin; userDataCache = new ConcurrentHashMap<>(); @@ -73,38 +104,23 @@ public UserDataManager(AdvancedCorePlugin plugin) { booleanColumns = new ArrayList<>(); timer = Executors.newScheduledThreadPool(1); loadKeys(); - - // run every hour to clear some cache - timer.scheduleAtFixedRate(new Runnable() { - - @Override - public void run() { - if (plugin != null && plugin.isEnabled()) { - clearNonNeededCachedUsers(); - } - } + timer.scheduleAtFixedRate(() -> { + if (plugin != null && plugin.isEnabled()) clearNonNeededCachedUsers(); }, 60 * 3, 60 * 60, TimeUnit.SECONDS); } public void addKey(UserDataKey userDataKey) { keys.add(userDataKey); - if (userDataKey instanceof UserDataKeyInt) { - intColumns.add(userDataKey.getKey()); - } else if (userDataKey instanceof UserDataKeyBoolean) { - booleanColumns.add(userDataKey.getKey()); - } - + if (userDataKey instanceof UserDataKeyInt) intColumns.add(userDataKey.getKey()); + else if (userDataKey instanceof UserDataKeyBoolean) booleanColumns.add(userDataKey.getKey()); } @Deprecated public void cacheUser(UUID uuid) { plugin.devDebug("Caching " + uuid.toString()); if (plugin.getOptions().getDebug().isDebug(DebugLevel.DEV)) { - try { - throw new Exception("caching here: " + uuid.toString()); - } catch (Exception e) { - e.printStackTrace(); - } + try { throw new Exception("caching here: " + uuid.toString()); } + catch (Exception e) { e.printStackTrace(); } } if (userDataCache.containsKey(uuid)) { UserDataCache data = userDataCache.get(uuid); @@ -112,18 +128,13 @@ public void cacheUser(UUID uuid) { data.cache(); } else { UserDataCache data = new UserDataCache(this, uuid).cache(); - if (data.hasCache()) { - userDataCache.put(uuid, data); - } + if (data.hasCache()) userDataCache.put(uuid, data); } - } public void cacheUser(UUID uuid, String playerName) { - if (playerName != null && !playerName.isEmpty()) { - if (!plugin.getOptions().isOnlineMode()) { - uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); - } + if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { + uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } plugin.devDebug("Caching " + uuid.toString()); if (userDataCache.containsKey(uuid)) { @@ -132,75 +143,38 @@ public void cacheUser(UUID uuid, String playerName) { data.cache(); } else { UserDataCache data = new UserDataCache(this, uuid).cache(); - if (data.hasCache()) { - userDataCache.put(uuid, data); - } + if (data.hasCache()) userDataCache.put(uuid, data); } } - public void cacheUserIfNeeded(UUID uuid) { - if (!userDataCache.containsKey(uuid)) { - cacheUser(uuid); - } - } + public void cacheUserIfNeeded(UUID uuid) { if (!userDataCache.containsKey(uuid)) cacheUser(uuid); } public void clearCache() { plugin.debug("Clearing cache: " + userDataCache.keySet().size()); - for (UserDataCache c : userDataCache.values()) { - c.clearCache(); - c.dump(); - } + for (UserDataCache c : userDataCache.values()) { c.clearCache(); c.dump(); } userDataCache.clear(); - } public void clearCacheBasic() { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().clearCacheBasic(); - } + if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic(); } public void clearNonNeededCachedUsers() { plugin.devDebug("Clearing cache for non online players (if any)"); ArrayList onlineUUIDS = new ArrayList<>(); - for (Player p : Bukkit.getOnlinePlayers()) { - onlineUUIDS.add(p.getUniqueId()); - } + for (Player p : Bukkit.getOnlinePlayers()) onlineUUIDS.add(p.getUniqueId()); int removed = 0; for (UUID uuid : userDataCache.keySet()) { - if (!onlineUUIDS.contains(uuid)) { - removeCache(uuid, null); - removed++; - } - } - if (removed > 0) { - plugin.devDebug("Removed " + removed + " cached users who are no longer online"); - } - } - - public boolean containsKey(UUID fromString) { - return userDataCache.containsKey(fromString); - } - - public UserDataCache getCache(UUID uuid) { - cacheUserIfNeeded(uuid); - return userDataCache.get(uuid); - } - - public boolean isBoolean(String str) { - return booleanColumns.contains(str); - } - - public boolean isCached(UUID uuid) { - if (userDataCache.containsKey(uuid)) { - return userDataCache.get(uuid).hasCache(); + if (!onlineUUIDS.contains(uuid)) { removeCache(uuid, null); removed++; } } - return false; + if (removed > 0) plugin.devDebug("Removed " + removed + " cached users who are no longer online"); } - public boolean isInt(String str) { - return intColumns.contains(str); - } + public boolean containsKey(UUID fromString) { return userDataCache.containsKey(fromString); } + public UserDataCache getCache(UUID uuid) { cacheUserIfNeeded(uuid); return userDataCache.get(uuid); } + public boolean isBoolean(String str) { return booleanColumns.contains(str); } + public boolean isCached(UUID uuid) { return userDataCache.containsKey(uuid) && userDataCache.get(uuid).hasCache(); } + public boolean isInt(String str) { return intColumns.contains(str); } private void loadKeys() { addKey(new UserDataKeyString("PlayerName").setColumnType("VARCHAR(30)")); @@ -215,23 +189,15 @@ private void loadKeys() { } public void removeCache(UUID uuid, String playerName) { - if (playerName != null && !playerName.isEmpty()) { - if (!plugin.getOptions().isOnlineMode()) { - uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); - } + if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { + uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } UserDataCache cache = getCache(uuid); - if (cache != null) { - cache.clearCache(); - } + if (cache != null) cache.clearCache(); userDataCache.remove(uuid); } public void updateCacheOnline() { - for (Player p : Bukkit.getOnlinePlayers()) { - if (isCached(p.getUniqueId())) { - cacheUser(p.getUniqueId()); - } - } + for (Player p : Bukkit.getOnlinePlayers()) if (isCached(p.getUniqueId())) cacheUser(p.getUniqueId()); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java index 48ca8c25e..8d997da1f 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 @@ -42,9 +42,7 @@ public BukkitUserCacheOwner(UserDataManager manager) { @Override public synchronized void bindFlushGate(Consumer gate) { Objects.requireNonNull(gate, "gate"); - if (flushGate != null && flushGate != gate) { - throw new IllegalStateException("Cache owner already belongs to another runtime"); - } + if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); flushGate = gate; } @@ -52,12 +50,11 @@ public synchronized void bindFlushGate(Consumer gate) { public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { Objects.requireNonNull(backend, "backend"); Objects.requireNonNull(gate, "gate"); - if (flushGate != null && flushGate != gate) { - throw new IllegalStateException("Cache owner already belongs to another runtime"); - } + if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); manager.bindSharedCacheInitializer(cacheInitializer); this.backend = backend; flushGate = gate; + manager.bindSharedSqlBackend(backend, gate); } @Override @@ -65,6 +62,7 @@ public synchronized void bindBackend(SqlUserBackend backend) { Objects.requireNonNull(backend, "backend"); manager.bindSharedCacheInitializer(cacheInitializer); this.backend = backend; + if (flushGate != null) manager.bindSharedSqlBackend(backend, flushGate); } private void bind(UserDataCache cache, UUID uuid) { @@ -90,17 +88,14 @@ public void requireBlockingAllowed() { public DataValue getIfPresent(UUID uuid, String key) { UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache == null) return null; - synchronized (cache) { - return cache.getCache() == null ? null : cache.getCache().get(key); - } + synchronized (cache) { return cache.getCache() == null ? null : cache.getCache().get(key); } } @Override public void populate(UUID uuid, HashMap values) { - UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, - ignored -> new UserDataCache(manager, uuid)); + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); - cache.updateCache(values); + cache.updateCachePreservingPending(values); } @Override @@ -127,9 +122,7 @@ public void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { requireBlockingAllowed(); storage.writeValues(type, values); }); - do { - cache.processChanges(); - } while (cache.hasChangesToProcess()); + do { cache.processChanges(); } while (cache.hasChangesToProcess()); } } @@ -144,13 +137,11 @@ public void remove(UUID uuid) { } } - @Override - public void clearAfterFlush() { - for (UUID uuid : cachedUsers()) remove(uuid); - } + @Override public void clearAfterFlush() { for (UUID uuid : cachedUsers()) remove(uuid); } @Override public void shutdown() { + manager.unbindSharedSqlBackend(backend); if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); 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 6ffe2b56e..1b722151d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java @@ -5,20 +5,19 @@ import java.util.List; import java.util.Map.Entry; import java.util.Objects; +import java.util.UUID; +import java.util.function.BiFunction; import java.util.function.Supplier; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueString; -/** - * Adapts the existing SQL providers. Plugin/table/UUID lookup stays live so a - * queued operation resolves the same current ownership as the legacy facade. - * Construction creates no user, cache, connection, executor, or schema. - */ +/** Adapts the existing SQL providers and the shared-runtime replacement route. */ public final class BukkitSqlUserStorage implements SqlUserStorage { private final Supplier plugin; private final Supplier uuid; @@ -28,73 +27,63 @@ public BukkitSqlUserStorage(Supplier plugin, Supplier T shared(BiFunction operation) { + return manager().withSharedSqlBackend(userId(), operation); } @Override public List readRow(UserStorage storage) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - return owner().getMysql().getExact(uuid.get()); - } + if (manager().hasSharedSqlBackend()) return shared((type, target) -> target.readRow(type)); + if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) return owner().getMysql().getExact(uuid.get()); return owner().getSQLiteUserTable().getExact(primary()); } @Override public boolean contains(UserStorage storage) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - return owner().getMysql().containsKey(uuid.get()); - } + if (manager().hasSharedSqlBackend()) return shared((type, target) -> target.contains(type)); + if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) return owner().getMysql().containsKey(uuid.get()); return owner().getSQLiteUserTable().containsKey(uuid.get()); } @Override public void delete(UserStorage storage) { - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - owner().getMysql().deletePlayer(uuid.get()); - } else { - owner().getSQLiteUserTable().delete(primary()); - } + if (manager().hasSharedSqlBackend()) { shared((type, target) -> { target.delete(type); return null; }); return; } + if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) owner().getMysql().deletePlayer(uuid.get()); + else owner().getSQLiteUserTable().delete(primary()); } @Override public void write(UserStorage storage, String key, DataValue value) { + if (manager().hasSharedSqlBackend()) { shared((type, target) -> { target.write(type, key, value); return null; }); return; } if (Objects.requireNonNull(storage, "storage") == UserStorage.SQLITE) { ArrayList columns = new ArrayList<>(); Column primary = primary(); columns.add(primary); columns.add(new Column(key, value)); owner().getSQLiteUserTable().update(primary, columns); - } else { - owner().getMysql().update(uuid.get(), key, value); - } + } else owner().getMysql().update(uuid.get(), key, value); } @Override public void writeValues(UserStorage storage, HashMap values) { + if (manager().hasSharedSqlBackend()) { shared((type, target) -> { target.writeValues(type, values); return null; }); return; } if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - // Preserve the existing bulk-write no-op while MySQL is unavailable. if (owner().getMysql() != null) { ArrayList columns = new ArrayList<>(); for (Entry entry : values.entrySet()) { - if (!entry.getKey().equals("uuid")) { - columns.add(new Column(entry.getKey(), entry.getValue())); - } + if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); } owner().getMysql().update(uuid.get(), columns, false); } } else { ArrayList columns = new ArrayList<>(); for (Entry entry : values.entrySet()) { - if (!entry.getKey().equals("uuid")) { - columns.add(new Column(entry.getKey(), entry.getValue())); - } - // Deliberately preserve legacy cumulative per-entry updates. - // Moving this call outside the loop would change write/failure ordering. + if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); owner().getSQLiteUserTable().update(primary(), columns); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java new file mode 100644 index 000000000..ba466444e --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java @@ -0,0 +1,58 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +class SharedLegacyBackendRoutingTest { + @Test + void legacyAdapterUsesBoundReplacementBackendRegardlessOfPluginStorageType() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + UUID uuid = UUID.randomUUID(); + RecordingStorage target = new RecordingStorage(); + SqlUserBackend backend = new SqlUserBackend() { + public UserStorage storageType() { return UserStorage.SQLITE; } + public SqlUserStorage user(UUID requested) { assertEquals(uuid, requested); return target; } + public List enumerateUsers() { return List.of(uuid); } + public boolean isOpen() { return true; } + public void close() {} + }; + manager.bindSharedSqlBackend(backend, Runnable::run); + when(plugin.getUserManager().getDataManager()).thenReturn(manager); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, () -> uuid.toString()); + DataValueInt value = new DataValueInt(9); + adapter.write(UserStorage.MYSQL, "Points", value); + assertEquals(UserStorage.SQLITE, target.lastStorage); + assertSame(value, target.lastValue); + manager.getTimer().shutdownNow(); + } + + private static final class RecordingStorage implements SqlUserStorage { + UserStorage lastStorage; + DataValue lastValue; + public List readRow(UserStorage storage) { lastStorage = storage; return List.of(); } + public boolean contains(UserStorage storage) { lastStorage = storage; return true; } + public void delete(UserStorage storage) { lastStorage = storage; } + public void write(UserStorage storage, String key, DataValue value) { lastStorage = storage; lastValue = value; } + public void writeValues(UserStorage storage, HashMap values) { lastStorage = storage; } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java new file mode 100644 index 000000000..d1c40e605 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java @@ -0,0 +1,38 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.ScheduledThreadPoolExecutor; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +class UserDataCachePopulationRaceTest { + @Test + void storageRefreshCannotOverwriteANewerQueuedCacheValue() { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + HashMap initial = new HashMap<>(); + initial.put("Points", new DataValueInt(1)); + cache.updateCache(initial); + cache.addChange(new UserDataChangeInt("Points", 7), true); + HashMap staleStorage = new HashMap<>(); + staleStorage.put("Points", new DataValueInt(1)); + cache.updateCachePreservingPending(staleStorage); + assertEquals(7, cache.getCache().get("Points").getInt()); + timer.shutdownNow(); + } +} From 8eb09843fa9586b7d88fb370b4002eebe8190b82 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:50:57 -0600 Subject: [PATCH 09/34] Preserve newer cache values across completed-write population races Capture an operation-local cache version before flush/load and merge only newer local mutations when publishing the SQL snapshot. Keep changes that have already left the pending queue, retain refreshed untouched fields, and reject snapshots targeting a replaced cache instance. Later full refreshes/populations win over older loads. No database wait is placed under the cache monitor or memory-only runtime admission path. Preserve concurrent f0fba33 legacy routing and e732e4b's SQL restack; their changes were compared before applying these five verified file blobs. Reuse the existing cache and queue; per-key metadata stores versions, not another copy of user values. Add eight deterministic headless regressions for pending/completed writes, memory-only changes, replacement, load failure, retry, overlapping loads and token identity. Local Java21 syntax parsing and reconstructed incremental whitespace checks passed. Full local Maven/JUnit and live-server/database tests are unavailable; GitHub Actions must validate the complete build and fresh shaded artifact. Follow-up to Codex 4000149174; no dependency, packaging or workflow changes. --- .../api/user/usercache/UserDataCache.java | 41 +++- .../user/runtime/BukkitUserCacheOwner.java | 30 +++ .../user/runtime/SharedUserDataRuntime.java | 4 +- .../core/user/runtime/UserCacheOwner.java | 13 + .../user/SharedCachePopulationRaceTest.java | 231 ++++++++++++++++++ 5 files changed, 313 insertions(+), 6 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java 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 e1b0ddb3d..48f20a48a 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 @@ -22,6 +22,10 @@ public class UserDataCache { @Getter private HashMap cache; private Queue cachedChanges; private final UserDataManager manager; + // Version metadata only; all user values stay in the existing cache. + private long snapshotVersion; + private long replacementVersion; + private final HashMap changedAt = new HashMap<>(); private boolean scheduled = false; private int inFlightBatches = 0; private volatile Consumer> sharedStorageWriter; @@ -57,6 +61,7 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue } if (change == null || cache == null || cachedChanges == null) return; cache.put(change.getKey(), change.toUserDataValue()); + if (sharedStorageWriter != null) changedAt.put(change.getKey(), ++snapshotVersion); if (queue) { cachedChanges.add(change); if (!scheduled) scheduleChanges(); @@ -65,6 +70,7 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue public synchronized UserDataCache cache() { if (uuid != null && cache != null) { + recordSnapshotReplacement(); AdvancedCoreUser user = getUser(); ArrayList keys = user.getUserData().getKeys(); HashMap data = user.getUserData().getValues(); @@ -96,11 +102,11 @@ public void clearCache() { gate = sharedFlushGate; if (gate == null) { if (hasChangesToProcess()) processChanges(); - if (cache != null) cache.clear(); + if (cache != null) { cache.clear(); recordSnapshotReplacement(); } return; } } - gate.accept(() -> { processChanges(); synchronized (this) { if (cache != null) cache.clear(); } }); + gate.accept(() -> { processChanges(); synchronized (this) { if (cache != null) { cache.clear(); recordSnapshotReplacement(); } } }); } public void clearChanges() { if (hasChangesToProcess()) processChanges(); } @@ -125,7 +131,7 @@ public void dump() { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } if (cachedChanges != null && !cachedChanges.isEmpty()) continue; - cache = null; cachedChanges = null; uuid = null; scheduled = false; return; + recordSnapshotReplacement(); cache = null; cachedChanges = null; uuid = null; scheduled = false; return; } } } @@ -149,7 +155,7 @@ public synchronized void setSharedStorageWriter(Consumer tempCache) { cache = tempCache == null ? new HashMap<>() : new HashMap<>(tempCache); + recordSnapshotReplacement(); } /** Merge a storage refresh without overwriting values that are queued or currently being persisted. */ @@ -238,5 +245,31 @@ public synchronized void updateCachePreservingPending(HashMap for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); } cache = refreshed; + recordSnapshotReplacement(); + } + + public synchronized long getSharedSnapshotVersion() { return snapshotVersion; } + + /** Merge only mutations newer than the load token, including already-flushed ones. */ + public synchronized HashMap updateSharedSnapshot(HashMap values, + long expectedVersion) { + if (cache == null || uuid == null) throw new IllegalStateException("Shared user cache is retired"); + if (expectedVersion < 0 || expectedVersion > snapshotVersion) { + throw new IllegalArgumentException("Invalid cache snapshot version"); + } + // A later completed population or explicit full-cache refresh already won. + if (replacementVersion > expectedVersion) return new HashMap<>(cache); + HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); + changedAt.forEach((key, version) -> { + if (version > expectedVersion) merged.put(key, cache.get(key)); + }); + cache = merged; + recordSnapshotReplacement(); + return new HashMap<>(cache); + } + + private void recordSnapshotReplacement() { + replacementVersion = ++snapshotVersion; + changedAt.clear(); } } 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 8d997da1f..aa77b05ab 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 @@ -7,6 +7,7 @@ import java.util.UUID; import java.util.function.Consumer; import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.atomic.AtomicReference; import org.bukkit.Bukkit; @@ -98,6 +99,35 @@ public void populate(UUID uuid, HashMap values) { cache.updateCachePreservingPending(values); } + private record CachePopulation(UUID uuid, UserDataCache cache, long version) implements PopulationToken {} + + @Override + public PopulationToken beginPopulation(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, + ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); + } + + @Override + public HashMap completePopulation(UUID uuid, HashMap values, + PopulationToken token) { + if (!(token instanceof CachePopulation expected) || !uuid.equals(expected.uuid())) { + throw new IllegalArgumentException("Population token does not belong to this user"); + } + AtomicReference> populated = new AtomicReference<>(); + // This map/cache critical section contains no storage work. A replacement + // cache cannot accidentally receive a snapshot loaded for an older instance. + manager.getUserDataCache().compute(uuid, (ignored, current) -> { + if (current != expected.cache()) { + throw new IllegalStateException("User cache changed while loading its database snapshot"); + } + populated.set(current.updateSharedSnapshot(values, expected.version())); + return current; + }); + return populated.get(); + } + @Override public void queueChange(UUID uuid, String key, DataValue value) { Objects.requireNonNull(value, "value"); 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 bd497e26b..897cbe44b 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 @@ -70,10 +70,10 @@ 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)); - cacheOwner.populate(uuid, values); - return values; + return cacheOwner.completePopulation(uuid, values, token); } public int startupForEach(BiConsumer> consumer, boolean populateCache) { 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 d1f6d4a89..5fff89e28 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 @@ -15,6 +15,19 @@ public interface UserCacheOwner { boolean isCached(UUID uuid); DataValue getIfPresent(UUID uuid, String key); void populate(UUID uuid, HashMap values); + + /** Opaque, operation-local snapshot token owned by the existing cache adapter. */ + interface PopulationToken {} + + /** Capture before flushing/loading so concurrent queued mutations can be retained. */ + default PopulationToken beginPopulation(UUID uuid) { return null; } + + /** Publish a database snapshot without overwriting mutations made since its token. */ + default HashMap completePopulation(UUID uuid, HashMap values, + PopulationToken token) { + populate(uuid, values); + return values; + } void queueChange(UUID uuid, String key, DataValue value); void flush(UUID uuid, SqlUserStorage storage); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java new file mode 100644 index 000000000..31c0fd8c4 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCachePopulationRaceTest.java @@ -0,0 +1,231 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +/** Real cache/queue and runtime; controlled in-memory provider, no live server/database. */ +@Timeout(15) +class SharedCachePopulationRaceTest { + @Test void pendingWriteWinsOverAnOlderDatabaseSnapshot() throws Exception { queuedWrite(false); } + @Test void writeFlushedBeforePublicationStillWinsOverAnOlderSnapshot() throws Exception { queuedWrite(true); } + + private void queuedWrite(boolean flushFirst) throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + // This must finish while the storage load is still held on another thread. + fixture.runtime.queueChange(fixture.uuid, "Points", new DataValueInt(7)); + if (flushFirst) { + fixture.runtime.flush(fixture.uuid); + assertFalse(fixture.cache.hasChangesToProcess()); + assertEquals(7, fixture.backend.rows.get("Points").getInt()); + } + fixture.backend.releaseRead.countDown(); + HashMap published = population.get(5, TimeUnit.SECONDS); + assertEquals(7, published.get("Points").getInt()); + assertEquals(2, published.get("Other").getInt(), "unmodified fields still refresh from storage"); + assertEquals(7, fixture.cached("Points")); + assertEquals(2, fixture.cached("Other")); + fixture.runtime.flush(fixture.uuid); + assertEquals(7, fixture.backend.rows.get("Points").getInt()); + assertEquals(7, fixture.cached("Points")); + fixture.noLegacyWrites(); + } + } + + @Test void memoryOnlyMutationDuringALoadIsNotDiscarded() throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + fixture.cache.addChange(new UserDataChangeInt("Points", 8), false); + fixture.backend.releaseRead.countDown(); + assertEquals(8, population.get(5, TimeUnit.SECONDS).get("Points").getInt()); + assertEquals(8, fixture.cached("Points")); + assertFalse(fixture.cache.hasChangesToProcess()); + assertEquals(1, fixture.backend.rows.get("Points").getInt()); + } + } + + @Test void aLaterFullCacheRefreshWinsOverAnOlderLoad() throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + fixture.cache.updateCache(values(9, 10)); + fixture.backend.releaseRead.countDown(); + assertEquals(9, population.get(5, TimeUnit.SECONDS).get("Points").getInt()); + assertEquals(9, fixture.cached("Points")); + assertEquals(10, fixture.cached("Other")); + } + } + + @Test void aReplacedCacheDoesNotReceiveAnOlderInstancesLoad() throws Exception { + try (Fixture fixture = new Fixture()) { + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + UserDataCache replacement = new UserDataCache(fixture.manager, fixture.uuid); + replacement.updateCache(values(11, 12)); + fixture.caches.put(fixture.uuid, replacement); + fixture.backend.releaseRead.countDown(); + assertInstanceOf(IllegalStateException.class, + assertThrows(ExecutionException.class, () -> population.get(5, TimeUnit.SECONDS)).getCause()); + assertSame(replacement, fixture.caches.get(fixture.uuid)); + assertEquals(11, fixture.cached("Points")); + assertEquals(12, fixture.cached("Other")); + } + } + + @Test void failedPopulationRetainsQueuedChangesAndCanBeRetried() throws Exception { + try (Fixture fixture = new Fixture()) { + IllegalStateException failure = new IllegalStateException("storage read failed"); + fixture.backend.readFailure = failure; + var population = fixture.worker.submit(() -> fixture.runtime.populate(fixture.uuid)); + await(fixture.backend.snapshotRead); + fixture.runtime.queueChange(fixture.uuid, "Points", new DataValueInt(13)); + fixture.backend.releaseRead.countDown(); + assertSame(failure, + assertThrows(ExecutionException.class, () -> population.get(5, TimeUnit.SECONDS)).getCause()); + assertEquals(13, fixture.cached("Points")); + assertTrue(fixture.cache.hasChangesToProcess()); + fixture.backend.readFailure = null; + assertEquals(13, fixture.runtime.populate(fixture.uuid).get("Points").getInt()); + assertEquals(13, fixture.backend.rows.get("Points").getInt()); + assertFalse(fixture.cache.hasChangesToProcess()); + } + } + + @Test void twoOverlappingPopulationsCannotPublishInReverseOrder() throws Exception { + try (Fixture fixture = new Fixture()) { + var first = fixture.owner.beginPopulation(fixture.uuid); + var second = fixture.owner.beginPopulation(fixture.uuid); + fixture.owner.completePopulation(fixture.uuid, values(14, 15), second); + var result = fixture.owner.completePopulation(fixture.uuid, values(1, 2), first); + assertEquals(14, result.get("Points").getInt()); + assertEquals(15, fixture.cached("Other")); + } + } + + @Test void populationTokenCannotBeAppliedToAnotherUser() throws Exception { + try (Fixture fixture = new Fixture()) { + var token = fixture.owner.beginPopulation(fixture.uuid); + assertThrows(IllegalArgumentException.class, () -> + fixture.owner.completePopulation(UUID.randomUUID(), values(99, 99), token)); + assertEquals(1, fixture.cached("Points")); + } + } + + private static HashMap values(int points, int other) { + return new HashMap<>(Map.of("Points", new DataValueInt(points), "Other", new DataValueInt(other))); + } + + private static void await(CountDownLatch latch) throws InterruptedException { + assertTrue(latch.await(5, TimeUnit.SECONDS), "storage snapshot did not reach the expected boundary"); + } + + private static final class Fixture implements AutoCloseable { + final UUID uuid = UUID.randomUUID(); + final AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + final UserDataManager manager = mock(UserDataManager.class, RETURNS_DEEP_STUBS); + final ConcurrentHashMap caches = new ConcurrentHashMap<>(); + final UserDataCache cache = new UserDataCache(manager, uuid); + final MemoryBackend backend = new MemoryBackend(uuid); + final BukkitUserCacheOwner owner; + final SharedUserDataRuntime runtime; + final ExecutorService worker = Executors.newSingleThreadExecutor(); + + Fixture() { + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getUserDataCache()).thenReturn(caches); + when(manager.isCached(any(UUID.class))).thenAnswer(call -> { + UserDataCache current = caches.get(call.getArgument(0, UUID.class)); + return current != null && current.hasCache(); + }); + cache.updateCache(values(1, 1)); + caches.put(uuid, cache); + owner = new BukkitUserCacheOwner(manager); + runtime = new SharedUserDataRuntime(backend, owner); + } + int cached(String key) { + return runtime.read(uuid, key, UserDataFetchMode.CACHE_ONLY, null, null).getInt(); + } + void noLegacyWrites() { + verify(plugin.getUserManager().getUser(uuid, false).getUserData(), never()).setValues(any(HashMap.class)); + } + public void close() throws Exception { + backend.releaseRead.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + runtime.close(); + } + } + + private static final class MemoryBackend implements SqlUserBackend { + final UUID uuid; + final Map rows = new ConcurrentHashMap<>(values(1, 2)); + final CountDownLatch snapshotRead = new CountDownLatch(1), releaseRead = new CountDownLatch(1); + final AtomicBoolean holdFirstRead = new AtomicBoolean(true); + volatile RuntimeException readFailure; + boolean open = true; + MemoryBackend(UUID uuid) { this.uuid = uuid; } + public UserStorage storageType() { return UserStorage.SQLITE; } + public boolean isOpen() { return open; } + public void close() { open = false; } + public List enumerateUsers() { return List.of(uuid); } + public SqlUserStorage user(UUID id) { + assertEquals(uuid, id); + return new SqlUserStorage() { + public List readRow(UserStorage type) { + List snapshot = new ArrayList<>(); + rows.forEach((key, value) -> snapshot.add(new Column(key, value))); + if (holdFirstRead.compareAndSet(true, false)) { + snapshotRead.countDown(); + try { await(releaseRead); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } + if (readFailure != null) throw readFailure; + return snapshot; + } + public boolean contains(UserStorage type) { return !rows.isEmpty(); } + public void delete(UserStorage type) { rows.clear(); } + public void write(UserStorage type, String key, DataValue value) { rows.put(key, value); } + public void writeValues(UserStorage type, HashMap values) { + assertTrue(open, "write after provider close"); + rows.putAll(values); + } + }; + } + } +} From f91009a6386bec4d4d75e2ae2ca3fd7177e7f238 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 10:56:58 -0600 Subject: [PATCH 10/34] Preserve legacy SQL access before optional cache-manager initialization Resolve the shared manager null-safely and use the captured instance for this operation. Legacy SQL providers remain usable before UserManager or UserDataManager exists; a bound shared route continues to handle failures through its lifecycle gate instead of silently falling back. Validate the requested storage before either route. Actions on 8eb0984 compiled and ran 452 tests. All eight new population-race regressions passed, but nine existing legacy-storage tests exposed this null-manager regression. Preserve those tests unchanged. Local Java21 syntax and incremental whitespace checks passed; full build requires CI. --- .../user/storage/BukkitSqlUserStorage.java | 32 ++++++++++++++----- 1 file changed, 24 insertions(+), 8 deletions(-) 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 1b722151d..802964469 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 @@ -30,36 +30,50 @@ public BukkitSqlUserStorage(Supplier plugin, Supplier T shared(BiFunction operation) { - return manager().withSharedSqlBackend(userId(), operation); + private T shared(UserDataManager manager, BiFunction operation) { + return manager.withSharedSqlBackend(userId(), operation); } @Override public List readRow(UserStorage storage) { - if (manager().hasSharedSqlBackend()) return shared((type, target) -> target.readRow(type)); + Objects.requireNonNull(storage, "storage"); + UserDataManager manager = sharedManager(); + if (manager != null) return shared(manager, (type, target) -> target.readRow(type)); if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) return owner().getMysql().getExact(uuid.get()); return owner().getSQLiteUserTable().getExact(primary()); } @Override public boolean contains(UserStorage storage) { - if (manager().hasSharedSqlBackend()) return shared((type, target) -> target.contains(type)); + Objects.requireNonNull(storage, "storage"); + UserDataManager manager = sharedManager(); + if (manager != null) return shared(manager, (type, target) -> target.contains(type)); if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) return owner().getMysql().containsKey(uuid.get()); return owner().getSQLiteUserTable().containsKey(uuid.get()); } @Override public void delete(UserStorage storage) { - if (manager().hasSharedSqlBackend()) { shared((type, target) -> { target.delete(type); return null; }); return; } + Objects.requireNonNull(storage, "storage"); + UserDataManager manager = sharedManager(); + if (manager != null) { shared(manager, (type, target) -> { target.delete(type); return null; }); return; } if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) owner().getMysql().deletePlayer(uuid.get()); else owner().getSQLiteUserTable().delete(primary()); } @Override public void write(UserStorage storage, String key, DataValue value) { - if (manager().hasSharedSqlBackend()) { shared((type, target) -> { target.write(type, key, value); return null; }); return; } + Objects.requireNonNull(storage, "storage"); + UserDataManager manager = sharedManager(); + if (manager != null) { shared(manager, (type, target) -> { target.write(type, key, value); return null; }); return; } if (Objects.requireNonNull(storage, "storage") == UserStorage.SQLITE) { ArrayList columns = new ArrayList<>(); Column primary = primary(); @@ -71,7 +85,9 @@ public void write(UserStorage storage, String key, DataValue value) { @Override public void writeValues(UserStorage storage, HashMap values) { - if (manager().hasSharedSqlBackend()) { shared((type, target) -> { target.writeValues(type, values); return null; }); return; } + Objects.requireNonNull(storage, "storage"); + UserDataManager manager = sharedManager(); + if (manager != null) { shared(manager, (type, target) -> { target.writeValues(type, values); return null; }); return; } if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { if (owner().getMysql() != null) { ArrayList columns = new ArrayList<>(); From 4c6b8ced838082ed4b1ac82dfc346b5fd87e848d Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:29:48 -0600 Subject: [PATCH 11/34] Add per-user lifecycle gate to cache owner --- .../core/user/runtime/UserCacheOwner.java | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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 5fff89e28..b7b39af9b 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 @@ -3,6 +3,7 @@ import java.util.HashMap; import java.util.Set; import java.util.UUID; +import java.util.function.BiConsumer; import java.util.function.Consumer; import com.bencodez.advancedcore.api.user.UserStorage; @@ -16,37 +17,33 @@ public interface UserCacheOwner { DataValue getIfPresent(UUID uuid, String key); void populate(UUID uuid, HashMap values); - /** Opaque, operation-local snapshot token owned by the existing cache adapter. */ interface PopulationToken {} - /** Capture before flushing/loading so concurrent queued mutations can be retained. */ default PopulationToken beginPopulation(UUID uuid) { return null; } - /** Publish a database snapshot without overwriting mutations made since its token. */ default HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { populate(uuid, values); return values; } + void queueChange(UUID uuid, String key, DataValue value); void flush(UUID uuid, SqlUserStorage storage); - /** Explicit type avoids inferring the destination from a platform plugin's current settings. */ default void flush(UUID uuid, UserStorage type, SqlUserStorage storage) { flush(uuid, storage); } - /** Include scheduled cache batches in the runtime admission/drain barrier. */ default void bindFlushGate(Consumer gate) {} - /** Bind scheduled writes to this provider; invoked before access and after a drained replacement. */ + /** Bind cache/legacy operations to the runtime's per-user shared barrier. */ + default void bindUserGate(BiConsumer gate) {} + default void bindBackend(SqlUserBackend backend) {} - /** Bind initial lifecycle state together. Stateful adapters override for atomic publication. */ default void bindLifecycle(SqlUserBackend backend, Consumer gate) { bindFlushGate(gate); bindBackend(backend); } - /** Platform adapters reject blocking work on server/entity threads before acquiring any barrier. */ default void requireBlockingAllowed() {} Set cachedUsers(); From 1f46458eac04c7ed9eafe9f3a5b93f1377d85254 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:30:14 -0600 Subject: [PATCH 12/34] Publish shared SQL route atomically --- .../api/user/usercache/UserDataManager.java | 49 ++++++++++--------- 1 file changed, 26 insertions(+), 23 deletions(-) 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 37a966f08..c571f7a06 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 @@ -8,6 +8,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; @@ -36,8 +37,14 @@ public class UserDataManager { @Getter private ConcurrentHashMap userDataCache; private volatile Consumer sharedCacheInitializer; - private volatile SqlUserBackend sharedSqlBackend; - private volatile Consumer sharedSqlGate; + private volatile SharedSqlRoute sharedSqlRoute; + + private record SharedSqlRoute(SqlUserBackend backend, BiConsumer gate) { + SharedSqlRoute { + Objects.requireNonNull(backend, "backend"); + Objects.requireNonNull(gate, "gate"); + } + } /** One shared owner for the existing map, including caches created by legacy callers. */ public final synchronized void bindSharedCacheInitializer(Consumer initializer) { @@ -58,39 +65,35 @@ void initializeSharedCache(UserDataCache cache) { } } - /** Publish the backend and the runtime admission gate as one legacy-facade route. */ + /** Publish backend and per-user lifecycle admission as one volatile immutable route. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate) { + sharedSqlRoute = new SharedSqlRoute(backend, gate); + } + + /** Compatibility overload for adapters that only need the global lifecycle gate. */ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Consumer gate) { - sharedSqlBackend = Objects.requireNonNull(backend, "backend"); - sharedSqlGate = Objects.requireNonNull(gate, "gate"); + Objects.requireNonNull(gate, "gate"); + bindSharedSqlBackend(backend, (uuid, operation) -> gate.accept(operation)); } public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { - if (sharedSqlBackend == expected) { - sharedSqlBackend = null; - sharedSqlGate = null; - } + SharedSqlRoute route = sharedSqlRoute; + if (route != null && route.backend() == expected) sharedSqlRoute = null; } - public final boolean hasSharedSqlBackend() { - return sharedSqlBackend != null && sharedSqlGate != null; - } + public final boolean hasSharedSqlBackend() { return sharedSqlRoute != null; } - /** - * Route legacy UserData/BukkitSqlUserStorage access through the currently selected - * shared backend while holding the same lifecycle admission used by runtime calls. - */ + /** Route one complete legacy SQL operation through one immutable backend/gate snapshot. */ public final T withSharedSqlBackend(UUID uuid, BiFunction operation) { Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(operation, "operation"); - Consumer gate = sharedSqlGate; - if (gate == null) throw new IllegalStateException("Shared SQL backend is not bound"); + SharedSqlRoute route = sharedSqlRoute; + if (route == null) throw new IllegalStateException("Shared SQL backend is not bound"); AtomicReference result = new AtomicReference<>(); - gate.accept(() -> { - SqlUserBackend selected = sharedSqlBackend; - if (selected == null || !selected.isOpen()) { - throw new IllegalStateException("Shared SQL backend is unavailable"); - } + route.gate().accept(uuid, () -> { + SqlUserBackend selected = route.backend(); + if (!selected.isOpen()) throw new IllegalStateException("Shared SQL backend is unavailable"); result.set(operation.apply(selected.storageType(), selected.user(uuid))); }); return result.get(); From b895abb041eb1c72573f4e4ff920c1517e057e40 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:30:58 -0600 Subject: [PATCH 13/34] Make shared cache refresh race-safe --- .../api/user/usercache/UserDataCache.java | 102 ++++++++++++------ 1 file changed, 69 insertions(+), 33 deletions(-) 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 48f20a48a..8a56c92c2 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 @@ -22,10 +22,10 @@ public class UserDataCache { @Getter private HashMap cache; private Queue cachedChanges; private final UserDataManager manager; - // Version metadata only; all user values stay in the existing cache. private long snapshotVersion; private long replacementVersion; private final HashMap changedAt = new HashMap<>(); + private final HashMap inFlightValues = new HashMap<>(); private boolean scheduled = false; private int inFlightBatches = 0; private volatile Consumer> sharedStorageWriter; @@ -68,30 +68,45 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue } } - public synchronized UserDataCache cache() { - if (uuid != null && cache != null) { - recordSnapshotReplacement(); - AdvancedCoreUser user = getUser(); - ArrayList keys = user.getUserData().getKeys(); - HashMap data = user.getUserData().getValues(); - ArrayList changedKeys = new ArrayList<>(); - for (UserDataKey dataKey : manager.getKeys()) { - String key = dataKey.getKey(); - keys.remove(key); - if (data.containsKey(key)) { - DataValue dataValue = data.get(key); - manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " + uuid + ", value: " + dataValue); - try { if (cache.containsKey(key) && !cache.get(key).toString().equals(dataValue.toString())) changedKeys.add(key); } - catch (Exception e) { manager.getPlugin().debug(e); } - cache.put(key, dataValue); - } else { - manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + uuid); - cache.put(key, dataKey.getDefault()); - } + /** Storage work happens outside this monitor; publish only after the full refresh succeeds. */ + public UserDataCache cache() { + initializeSharedStorage(); + UUID currentUuid; + long expectedVersion; + HashMap before; + synchronized (this) { + if (uuid == null || cache == null) return this; + currentUuid = uuid; + expectedVersion = snapshotVersion; + before = new HashMap<>(cache); + } + + AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); + ArrayList keys = user.getUserData().getKeys(); + HashMap data = user.getUserData().getValues(); + HashMap refreshed = new HashMap<>(); + for (UserDataKey dataKey : manager.getKeys()) { + String key = dataKey.getKey(); + keys.remove(key); + DataValue dataValue = data.containsKey(key) ? data.get(key) : dataKey.getDefault(); + if (data.containsKey(key)) { + manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " + + currentUuid + ", value: " + dataValue); + } else { + manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + currentUuid); + } + refreshed.put(key, dataValue); + } + HashMap published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid); + ArrayList changedKeys = new ArrayList<>(); + for (Entry entry : published.entrySet()) { + DataValue prior = before.get(entry.getKey()); + if (prior != null && entry.getValue() != null && !prior.toString().equals(entry.getValue().toString())) { + changedKeys.add(entry.getKey()); } - if (!changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); - if (!keys.isEmpty()) manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); } + if (!changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); + if (!keys.isEmpty()) manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); return this; } @@ -141,6 +156,18 @@ public void dump() { public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } + /** Wait only for a batch that already selected the legacy writer before shared routing is published. */ + public synchronized void awaitLegacyBatchesBeforeSharedBinding() { + if (sharedStorageWriter != null) return; + while (inFlightBatches > 0) { + try { wait(); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while draining legacy user batch", interrupted); + } + } + } + public synchronized void configureSharedStorage(Consumer> writer, Consumer gate) { if (sharedFlushGate != null && sharedFlushGate != gate) throw new IllegalStateException("Shared user cache already belongs to another runtime"); if (sharedFlushGate == null && gate != null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); @@ -180,6 +207,10 @@ private void processChangesInternal(boolean admitted) { if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) return; UserDataChange change; while ((change = cachedChanges.poll()) != null) changes.add(change); + inFlightValues.clear(); + for (UserDataChange changeEntry : changes) { + inFlightValues.put(changeEntry.getKey(), changeEntry.toUserDataValue()); + } inFlightBatches++; if (writer != null) sharedBatchThread = Thread.currentThread(); } @@ -209,7 +240,11 @@ private synchronized void requeueChanges(ArrayList changes) { } private synchronized void finishInFlightBatch() { - if (inFlightBatches > 0) { inFlightBatches--; if (sharedBatchThread == Thread.currentThread()) sharedBatchThread = null; } + if (inFlightBatches > 0) { + inFlightBatches--; + if (inFlightBatches == 0) inFlightValues.clear(); + if (sharedBatchThread == Thread.currentThread()) sharedBatchThread = null; + } notifyAll(); } @@ -237,10 +272,10 @@ public synchronized void updateCache(HashMap tempCache) { recordSnapshotReplacement(); } - /** Merge a storage refresh without overwriting values that are queued or currently being persisted. */ + /** Merge a storage refresh with only values actually queued or in the active batch. */ public synchronized void updateCachePreservingPending(HashMap storageValues) { HashMap refreshed = storageValues == null ? new HashMap<>() : new HashMap<>(storageValues); - if (inFlightBatches > 0 && cache != null) refreshed.putAll(cache); + refreshed.putAll(inFlightValues); if (cachedChanges != null) { for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); } @@ -250,18 +285,19 @@ public synchronized void updateCachePreservingPending(HashMap public synchronized long getSharedSnapshotVersion() { return snapshotVersion; } - /** Merge only mutations newer than the load token, including already-flushed ones. */ public synchronized HashMap updateSharedSnapshot(HashMap values, long expectedVersion) { - if (cache == null || uuid == null) throw new IllegalStateException("Shared user cache is retired"); - if (expectedVersion < 0 || expectedVersion > snapshotVersion) { - throw new IllegalArgumentException("Invalid cache snapshot version"); - } - // A later completed population or explicit full-cache refresh already won. + return updateSharedSnapshot(values, expectedVersion, uuid); + } + + private synchronized HashMap updateSharedSnapshot(HashMap values, + long expectedVersion, UUID expectedUuid) { + 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); HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); changedAt.forEach((key, version) -> { - if (version > expectedVersion) merged.put(key, cache.get(key)); + if (version > expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; recordSnapshotReplacement(); From 36370dd7d01f4c7a0f9396b86f267c8d533b9a3f Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:31:40 -0600 Subject: [PATCH 14/34] Bind caches through per-user lifecycle barrier --- .../user/runtime/BukkitUserCacheOwner.java | 43 +++++++++++++++---- 1 file changed, 34 insertions(+), 9 deletions(-) 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 aa77b05ab..c29de0062 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 @@ -5,9 +5,10 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; -import java.util.function.Consumer; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.atomic.AtomicReference; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.bukkit.Bukkit; @@ -28,13 +29,14 @@ public final class BukkitUserCacheOwner implements UserCacheOwner { private final UserDataManager manager; private volatile SqlUserBackend backend; private volatile Consumer flushGate; + private volatile BiConsumer userGate; private final Consumer cacheInitializer; public BukkitUserCacheOwner(UserDataManager manager) { this.manager = Objects.requireNonNull(manager, "manager"); cacheInitializer = cache -> { Consumer gate; - synchronized (this) { gate = flushGate; } + synchronized (this) { gate = cacheGate(cache.getUuid()); } if (gate == null) bind(cache, cache.getUuid()); else gate.accept(() -> bind(cache, cache.getUuid())); }; @@ -47,15 +49,31 @@ public synchronized void bindFlushGate(Consumer gate) { flushGate = gate; } + @Override + public synchronized void bindUserGate(BiConsumer gate) { + Objects.requireNonNull(gate, "gate"); + if (userGate != null && userGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + userGate = gate; + } + @Override public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { Objects.requireNonNull(backend, "backend"); Objects.requireNonNull(gate, "gate"); if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); - manager.bindSharedCacheInitializer(cacheInitializer); + + // A batch that already selected the legacy writer must finish before any + // new backend is visible to cache/runtime reads. + for (UserDataCache cache : manager.getUserDataCache().values()) { + cache.awaitLegacyBatchesBeforeSharedBinding(); + } + this.backend = backend; flushGate = gate; - manager.bindSharedSqlBackend(backend, gate); + manager.bindSharedCacheInitializer(cacheInitializer); + BiConsumer perUser = userGate; + if (perUser != null) manager.bindSharedSqlBackend(backend, perUser); + else manager.bindSharedSqlBackend(backend, gate); } @Override @@ -63,7 +81,15 @@ public synchronized void bindBackend(SqlUserBackend backend) { Objects.requireNonNull(backend, "backend"); manager.bindSharedCacheInitializer(cacheInitializer); this.backend = backend; - if (flushGate != null) manager.bindSharedSqlBackend(backend, flushGate); + BiConsumer perUser = userGate; + if (perUser != null) manager.bindSharedSqlBackend(backend, perUser); + else if (flushGate != null) manager.bindSharedSqlBackend(backend, flushGate); + } + + private Consumer cacheGate(UUID uuid) { + BiConsumer perUser = userGate; + if (perUser != null && uuid != null) return operation -> perUser.accept(uuid, operation); + return flushGate; } private void bind(UserDataCache cache, UUID uuid) { @@ -72,7 +98,7 @@ private void bind(UserDataCache cache, UUID uuid) { cache.configureSharedStorage(values -> { requireBlockingAllowed(); selected.user(uuid).writeValues(selected.storageType(), values); - }, flushGate); + }, cacheGate(uuid)); } } @@ -116,8 +142,6 @@ public HashMap completePopulation(UUID uuid, HashMap> populated = new AtomicReference<>(); - // This map/cache critical section contains no storage work. A replacement - // cache cannot accidentally receive a snapshot loaded for an older instance. manager.getUserDataCache().compute(uuid, (ignored, current) -> { if (current != expected.cache()) { throw new IllegalStateException("User cache changed while loading its database snapshot"); @@ -171,7 +195,8 @@ public void remove(UUID uuid) { @Override public void shutdown() { - manager.unbindSharedSqlBackend(backend); + // Keep the immutable shared route installed. Its runtime gate is already + // retiring, so late legacy calls fail instead of falling back to the old provider. if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); From e7e8adce35f00f9620dba9a3a11a3cac090cf690 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:32:12 -0600 Subject: [PATCH 15/34] Serialize per-user removal against writes --- .../user/runtime/SharedUserDataRuntime.java | 80 ++++++++++++++----- 1 file changed, 58 insertions(+), 22 deletions(-) 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 897cbe44b..9e0fadaa0 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 @@ -22,8 +22,10 @@ /** Coordinates one existing cache/queue and its SQL provider; storage work runs on a worker. */ public final class SharedUserDataRuntime implements AutoCloseable { + private static final int USER_LOCK_STRIPES = 64; private final UserCacheOwner cacheOwner; private final ReentrantReadWriteLock lifecycle = new ReentrantReadWriteLock(true); + private final ReentrantReadWriteLock[] userLocks = createUserLocks(); private final AtomicBoolean retiring = new AtomicBoolean(); private final Object closeLock = new Object(); private volatile SqlUserBackend backend; @@ -33,13 +35,14 @@ public final class SharedUserDataRuntime implements AutoCloseable { public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) { this.backend = Objects.requireNonNull(backend, "backend"); this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); + cacheOwner.bindUserGate((uuid, batch) -> userAccess(uuid, () -> { batch.run(); return null; })); cacheOwner.bindLifecycle(backend, batch -> access(() -> { batch.run(); return null; })); } public DataValue read(UUID uuid, String key, UserDataFetchMode mode, HashMap temporaryCache, DataValue defaultValue) { - return access(() -> { - Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(uuid, "uuid"); + return userAccess(uuid, () -> { Objects.requireNonNull(mode, "mode"); if (key == null || key.isEmpty()) return defaultValue; if (mode.allowTempCache() && temporaryCache != null) { @@ -66,7 +69,8 @@ public DataValue read(UUID uuid, String key, UserDataFetchMode mode, } public HashMap populate(UUID uuid) { - return storageAccess(() -> populateInternal(Objects.requireNonNull(uuid, "uuid"))); + Objects.requireNonNull(uuid, "uuid"); + return storageUserAccess(uuid, () -> populateInternal(uuid)); } private HashMap populateInternal(UUID uuid) { @@ -80,19 +84,20 @@ public int startupForEach(BiConsumer> consumer, return storageAccess(() -> { Objects.requireNonNull(consumer, "consumer"); int[] count = { 0 }; - backend.forEachUser(uuid -> { + backend.forEachUser(uuid -> userAccess(uuid, () -> { HashMap values = populateCache ? populateInternal(uuid) : SqlUserDataAccess.convert(readStorageRow(uuid)); consumer.accept(uuid, values); count[0]++; - }); + return null; + })); return count[0]; }); } public void queueChange(UUID uuid, String key, DataValue value) { - access(() -> { - Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(uuid, "uuid"); + userAccess(uuid, () -> { Objects.requireNonNull(key, "key"); Objects.requireNonNull(value, "value"); if (!cacheOwner.isCached(uuid)) { @@ -105,7 +110,8 @@ public void queueChange(UUID uuid, String key, DataValue value) { } public void flush(UUID uuid) { - storageAccess(() -> { flushInternal(Objects.requireNonNull(uuid, "uuid")); return null; }); + Objects.requireNonNull(uuid, "uuid"); + storageUserAccess(uuid, () -> { flushInternal(uuid); return null; }); } private void flushInternal(UUID uuid) { @@ -113,7 +119,12 @@ private void flushInternal(UUID uuid) { } public void flushAll() { - storageAccess(() -> { flushAllInternal(); return null; }); + storageAccess(() -> { + for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) { + userAccess(uuid, () -> { flushInternal(uuid); return null; }); + } + return null; + }); } private void flushAllInternal() { @@ -141,8 +152,9 @@ public void replaceBackend(SqlUserBackend replacement) { } public void remove(UUID uuid) { - storageAccess(() -> { - Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(uuid, "uuid"); + cacheOwner.requireBlockingAllowed(); + userExclusiveAccess(uuid, () -> { flushInternal(uuid); backend.user(uuid).delete(backend.storageType()); cacheOwner.remove(uuid); @@ -154,11 +166,6 @@ public void remove(UUID uuid) { public boolean isClosed() { return closed; } public boolean isRetiring() { return retiring.get(); } - /** - * Stop admission now and drain on the supplied worker. Completion represents - * actual flush/cleanup, not task submission. A failed attempt retains the - * unflushed queue/provider and can be retried; it never reopens admission. - */ public CompletionStage closeAsync(Executor executor) { Objects.requireNonNull(executor, "executor"); rejectReentrantTransition(); @@ -198,7 +205,6 @@ public CompletionStage closeAsync(Executor executor) { return result.minimalCompletionStage(); } - /** Blocking worker-only form. Server callbacks must use closeAsync and observe its stage. */ @Override public void close() { if (closed) return; @@ -212,13 +218,11 @@ public void close() { } } - /** Lifecycle admission for memory-only cache work; never performs a platform blocking check itself. */ private T access(Supplier operation) { boolean admitted = lifecycle.getReadHoldCount() > 0 || lifecycle.isWriteLockedByCurrentThread(); if (!admitted) requireOpen(); lifecycle.readLock().lock(); try { - // Nested notification work belongs to the already-admitted batch. if (!admitted) requireOpen(); return operation.get(); } finally { @@ -231,6 +235,40 @@ private T storageAccess(Supplier operation) { return access(operation); } + private T userAccess(UUID uuid, Supplier operation) { + return access(() -> { + ReentrantReadWriteLock.ReadLock lock = userLock(uuid).readLock(); + lock.lock(); + try { return operation.get(); } + finally { lock.unlock(); } + }); + } + + private T storageUserAccess(UUID uuid, Supplier operation) { + cacheOwner.requireBlockingAllowed(); + return userAccess(uuid, operation); + } + + private T userExclusiveAccess(UUID uuid, Supplier operation) { + return access(() -> { + ReentrantReadWriteLock.WriteLock lock = userLock(uuid).writeLock(); + lock.lock(); + try { return operation.get(); } + finally { lock.unlock(); } + }); + } + + private ReentrantReadWriteLock userLock(UUID uuid) { + int index = (uuid.hashCode() & Integer.MAX_VALUE) % USER_LOCK_STRIPES; + return userLocks[index]; + } + + private static ReentrantReadWriteLock[] createUserLocks() { + ReentrantReadWriteLock[] locks = new ReentrantReadWriteLock[USER_LOCK_STRIPES]; + for (int i = 0; i < locks.length; i++) locks[i] = new ReentrantReadWriteLock(true); + return locks; + } + private void rejectReentrantTransition() { if (lifecycle.getReadHoldCount() > 0 || lifecycle.isWriteLockedByCurrentThread()) { throw new IllegalStateException("Cannot replace or close the user runtime from an active user callback"); @@ -244,9 +282,7 @@ private List readStorageRow(UUID uuid) { private DataValue find(List row, String key, DataValue defaultValue) { if (row != null) { for (Column column : row) { - if (column.getName().equals(key)) { - return column.getValue() == null ? defaultValue : column.getValue(); - } + if (column.getName().equals(key)) return column.getValue() == null ? defaultValue : column.getValue(); } } return defaultValue; From 8cbfc5041e83d169d76d59fd2c6eac7e7bdaae8d Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:39:11 -0600 Subject: [PATCH 16/34] Fix shared user lifecycle follow-up regressions --- .../api/user/usercache/UserDataCache.java | 32 +++---- .../user/runtime/BukkitUserCacheOwner.java | 50 +++++++---- .../user/runtime/SharedUserDataRuntime.java | 84 ++++++------------- .../core/user/runtime/UserCacheOwner.java | 10 ++- .../tests/storage/SqlUserDataFacadeTest.java | 22 ++--- .../SharedCacheBindingRegressionTest.java | 49 +++++------ 6 files changed, 100 insertions(+), 147 deletions(-) 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 8a56c92c2..d9002e0b9 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 @@ -68,7 +68,6 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue } } - /** Storage work happens outside this monitor; publish only after the full refresh succeeds. */ public UserDataCache cache() { initializeSharedStorage(); UUID currentUuid; @@ -80,7 +79,6 @@ public UserDataCache cache() { expectedVersion = snapshotVersion; before = new HashMap<>(cache); } - AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); ArrayList keys = user.getUserData().getKeys(); HashMap data = user.getUserData().getValues(); @@ -89,21 +87,15 @@ public UserDataCache cache() { String key = dataKey.getKey(); keys.remove(key); DataValue dataValue = data.containsKey(key) ? data.get(key) : dataKey.getDefault(); - if (data.containsKey(key)) { - manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " - + currentUuid + ", value: " + dataValue); - } else { - manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + currentUuid); - } + if (data.containsKey(key)) manager.getPlugin().devDebug("Caching " + dataValue.getTypeName() + " " + key + " for " + currentUuid + ", value: " + dataValue); + else manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + currentUuid); refreshed.put(key, dataValue); } HashMap published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid); ArrayList changedKeys = new ArrayList<>(); for (Entry entry : published.entrySet()) { DataValue prior = before.get(entry.getKey()); - if (prior != null && entry.getValue() != null && !prior.toString().equals(entry.getValue().toString())) { - changedKeys.add(entry.getKey()); - } + if (prior != null && entry.getValue() != null && !prior.toString().equals(entry.getValue().toString())) changedKeys.add(entry.getKey()); } if (!changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); if (!keys.isEmpty()) manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); @@ -156,7 +148,6 @@ public void dump() { public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } - /** Wait only for a batch that already selected the legacy writer before shared routing is published. */ public synchronized void awaitLegacyBatchesBeforeSharedBinding() { if (sharedStorageWriter != null) return; while (inFlightBatches > 0) { @@ -208,8 +199,12 @@ private void processChangesInternal(boolean admitted) { UserDataChange change; while ((change = cachedChanges.poll()) != null) changes.add(change); inFlightValues.clear(); - for (UserDataChange changeEntry : changes) { - inFlightValues.put(changeEntry.getKey(), changeEntry.toUserDataValue()); + try { + for (UserDataChange changeEntry : changes) inFlightValues.put(changeEntry.getKey(), changeEntry.toUserDataValue()); + } catch (RuntimeException | Error preparationFailure) { + inFlightValues.clear(); + requeueChanges(changes); + throw preparationFailure; } inFlightBatches++; if (writer != null) sharedBatchThread = Thread.currentThread(); @@ -272,13 +267,10 @@ public synchronized void updateCache(HashMap tempCache) { recordSnapshotReplacement(); } - /** Merge a storage refresh with only values actually queued or in the active batch. */ public synchronized void updateCachePreservingPending(HashMap storageValues) { HashMap refreshed = storageValues == null ? new HashMap<>() : new HashMap<>(storageValues); refreshed.putAll(inFlightValues); - if (cachedChanges != null) { - for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); - } + if (cachedChanges != null) for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); cache = refreshed; recordSnapshotReplacement(); } @@ -296,9 +288,7 @@ private synchronized HashMap updateSharedSnapshot(HashMap snapshotVersion) throw new IllegalArgumentException("Invalid cache snapshot version"); if (replacementVersion > expectedVersion) return new HashMap<>(cache); HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); - changedAt.forEach((key, version) -> { - if (version > expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); - }); + changedAt.forEach((key, version) -> { if (version > expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; recordSnapshotReplacement(); return new HashMap<>(cache); 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 c29de0062..84b8d3f86 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 @@ -5,6 +5,7 @@ import java.util.Objects; import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ScheduledThreadPoolExecutor; import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; @@ -30,13 +31,13 @@ public final class BukkitUserCacheOwner implements UserCacheOwner { private volatile SqlUserBackend backend; private volatile Consumer flushGate; private volatile BiConsumer userGate; + private final ConcurrentHashMap> cacheGates = new ConcurrentHashMap<>(); private final Consumer cacheInitializer; public BukkitUserCacheOwner(UserDataManager manager) { this.manager = Objects.requireNonNull(manager, "manager"); cacheInitializer = cache -> { - Consumer gate; - synchronized (this) { gate = cacheGate(cache.getUuid()); } + Consumer gate = cacheGate(cache.getUuid()); if (gate == null) bind(cache, cache.getUuid()); else gate.accept(() -> bind(cache, cache.getUuid())); }; @@ -57,23 +58,32 @@ public synchronized void bindUserGate(BiConsumer gate) { } @Override - public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { + public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer perUserGate) { Objects.requireNonNull(backend, "backend"); Objects.requireNonNull(gate, "gate"); + Objects.requireNonNull(perUserGate, "perUserGate"); if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + if (userGate != null && userGate != perUserGate) throw new IllegalStateException("Cache owner already belongs to another runtime"); - // A batch that already selected the legacy writer must finish before any - // new backend is visible to cache/runtime reads. - for (UserDataCache cache : manager.getUserDataCache().values()) { - cache.awaitLegacyBatchesBeforeSharedBinding(); - } + // Do not publish any replacement route until a legacy batch that already + // selected the old writer has completed. + for (UserDataCache cache : manager.getUserDataCache().values()) cache.awaitLegacyBatchesBeforeSharedBinding(); + // This can fail in integration code; keep owner fields unpublished until it succeeds. + manager.bindSharedCacheInitializer(cacheInitializer); this.backend = backend; flushGate = gate; - manager.bindSharedCacheInitializer(cacheInitializer); - BiConsumer perUser = userGate; - if (perUser != null) manager.bindSharedSqlBackend(backend, perUser); - else manager.bindSharedSqlBackend(backend, gate); + userGate = perUserGate; + cacheGates.clear(); + manager.bindSharedSqlBackend(backend, perUserGate); + } + + @Override + public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { + BiConsumer existing = userGate; + if (existing == null) existing = (uuid, operation) -> gate.accept(operation); + bindLifecycle(backend, gate, existing); } @Override @@ -88,7 +98,13 @@ public synchronized void bindBackend(SqlUserBackend backend) { private Consumer cacheGate(UUID uuid) { BiConsumer perUser = userGate; - if (perUser != null && uuid != null) return operation -> perUser.accept(uuid, operation); + if (perUser != null && uuid != null) { + return cacheGates.computeIfAbsent(uuid, id -> operation -> { + BiConsumer current = userGate; + if (current == null) throw new IllegalStateException("Shared user lifecycle is not bound"); + current.accept(id, operation); + }); + } return flushGate; } @@ -143,9 +159,7 @@ public HashMap completePopulation(UUID uuid, HashMap> populated = new AtomicReference<>(); manager.getUserDataCache().compute(uuid, (ignored, current) -> { - if (current != expected.cache()) { - throw new IllegalStateException("User cache changed while loading its database snapshot"); - } + if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); populated.set(current.updateSharedSnapshot(values, expected.version())); return current; }); @@ -189,14 +203,14 @@ public void remove(UUID uuid) { cache.retireAfterSharedFlush(); manager.getUserDataCache().remove(uuid, cache); } + cacheGates.remove(uuid); } @Override public void clearAfterFlush() { for (UUID uuid : cachedUsers()) remove(uuid); } @Override public void shutdown() { - // Keep the immutable shared route installed. Its runtime gate is already - // retiring, so late legacy calls fail instead of falling back to the old provider. + // Keep the rejecting shared route installed until the manager itself is disposed. if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); 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 9e0fadaa0..7e9890f33 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 @@ -12,6 +12,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.BiConsumer; +import java.util.function.Consumer; import java.util.function.Supplier; import com.bencodez.advancedcore.api.user.UserDataFetchMode; @@ -35,8 +36,9 @@ public final class SharedUserDataRuntime implements AutoCloseable { public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) { this.backend = Objects.requireNonNull(backend, "backend"); this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); - cacheOwner.bindUserGate((uuid, batch) -> userAccess(uuid, () -> { batch.run(); return null; })); - cacheOwner.bindLifecycle(backend, batch -> access(() -> { batch.run(); return null; })); + Consumer lifecycleGate = batch -> access(() -> { batch.run(); return null; }); + BiConsumer perUserGate = (uuid, batch) -> userAccess(uuid, () -> { batch.run(); return null; }); + cacheOwner.bindLifecycle(backend, lifecycleGate, perUserGate); } public DataValue read(UUID uuid, String key, UserDataFetchMode mode, @@ -60,9 +62,7 @@ public DataValue read(UUID uuid, String key, UserDataFetchMode mode, if (cached != null) return cached; } if (!mode.allowStorageLookup()) return defaultValue; - } else if (!mode.allowStorageLookup()) { - return defaultValue; - } + } else if (!mode.allowStorageLookup()) return defaultValue; cacheOwner.requireBlockingAllowed(); return find(readStorageRow(uuid), key, defaultValue); }); @@ -85,8 +85,7 @@ public int startupForEach(BiConsumer> consumer, Objects.requireNonNull(consumer, "consumer"); int[] count = { 0 }; backend.forEachUser(uuid -> userAccess(uuid, () -> { - HashMap values = populateCache ? populateInternal(uuid) - : SqlUserDataAccess.convert(readStorageRow(uuid)); + HashMap values = populateCache ? populateInternal(uuid) : SqlUserDataAccess.convert(readStorageRow(uuid)); consumer.accept(uuid, values); count[0]++; return null; @@ -114,22 +113,16 @@ public void flush(UUID uuid) { storageUserAccess(uuid, () -> { flushInternal(uuid); return null; }); } - private void flushInternal(UUID uuid) { - cacheOwner.flush(uuid, backend.storageType(), backend.user(uuid)); - } + private void flushInternal(UUID uuid) { cacheOwner.flush(uuid, backend.storageType(), backend.user(uuid)); } public void flushAll() { storageAccess(() -> { - for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) { - userAccess(uuid, () -> { flushInternal(uuid); return null; }); - } + for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) userAccess(uuid, () -> { flushInternal(uuid); return null; }); return null; }); } - private void flushAllInternal() { - for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) flushInternal(uuid); - } + private void flushAllInternal() { for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) flushInternal(uuid); } public void replaceBackend(SqlUserBackend replacement) { rejectReentrantTransition(); @@ -146,9 +139,7 @@ public void replaceBackend(SqlUserBackend replacement) { cacheOwner.bindBackend(replacement); backend = replacement; previous.close(); - } finally { - lifecycle.writeLock().unlock(); - } + } finally { lifecycle.writeLock().unlock(); } } public void remove(UUID uuid) { @@ -171,9 +162,7 @@ public CompletionStage closeAsync(Executor executor) { rejectReentrantTransition(); CompletableFuture result; synchronized (closeLock) { - if (closeAttempt != null && !closeAttempt.isCompletedExceptionally()) { - return closeAttempt.minimalCompletionStage(); - } + if (closeAttempt != null && !closeAttempt.isCompletedExceptionally()) return closeAttempt.minimalCompletionStage(); retiring.set(true); result = new CompletableFuture<>(); closeAttempt = result; @@ -191,17 +180,11 @@ public CompletionStage closeAsync(Executor executor) { backend.close(); closed = true; } - } finally { - lifecycle.writeLock().unlock(); - } + } finally { lifecycle.writeLock().unlock(); } result.complete(null); - } catch (Throwable failure) { - result.completeExceptionally(failure); - } + } catch (Throwable failure) { result.completeExceptionally(failure); } }); - } catch (RuntimeException | Error failure) { - result.completeExceptionally(failure); - } + } catch (RuntimeException | Error failure) { result.completeExceptionally(failure); } return result.minimalCompletionStage(); } @@ -209,9 +192,8 @@ public CompletionStage closeAsync(Executor executor) { public void close() { if (closed) return; cacheOwner.requireBlockingAllowed(); - try { - closeAsync(Runnable::run).toCompletableFuture().join(); - } catch (CompletionException failure) { + try { closeAsync(Runnable::run).toCompletableFuture().join(); } + catch (CompletionException failure) { if (failure.getCause() instanceof RuntimeException runtime) throw runtime; if (failure.getCause() instanceof Error error) throw error; throw failure; @@ -225,36 +207,26 @@ private T access(Supplier operation) { try { if (!admitted) requireOpen(); return operation.get(); - } finally { - lifecycle.readLock().unlock(); - } + } finally { lifecycle.readLock().unlock(); } } - private T storageAccess(Supplier operation) { - cacheOwner.requireBlockingAllowed(); - return access(operation); - } + private T storageAccess(Supplier operation) { cacheOwner.requireBlockingAllowed(); return access(operation); } private T userAccess(UUID uuid, Supplier operation) { return access(() -> { ReentrantReadWriteLock.ReadLock lock = userLock(uuid).readLock(); lock.lock(); - try { return operation.get(); } - finally { lock.unlock(); } + try { return operation.get(); } finally { lock.unlock(); } }); } - private T storageUserAccess(UUID uuid, Supplier operation) { - cacheOwner.requireBlockingAllowed(); - return userAccess(uuid, operation); - } + private T storageUserAccess(UUID uuid, Supplier operation) { cacheOwner.requireBlockingAllowed(); return userAccess(uuid, operation); } private T userExclusiveAccess(UUID uuid, Supplier operation) { return access(() -> { ReentrantReadWriteLock.WriteLock lock = userLock(uuid).writeLock(); lock.lock(); - try { return operation.get(); } - finally { lock.unlock(); } + try { return operation.get(); } finally { lock.unlock(); } }); } @@ -270,21 +242,13 @@ private static ReentrantReadWriteLock[] createUserLocks() { } private void rejectReentrantTransition() { - if (lifecycle.getReadHoldCount() > 0 || lifecycle.isWriteLockedByCurrentThread()) { - throw new IllegalStateException("Cannot replace or close the user runtime from an active user callback"); - } + if (lifecycle.getReadHoldCount() > 0 || lifecycle.isWriteLockedByCurrentThread()) throw new IllegalStateException("Cannot replace or close the user runtime from an active user callback"); } - private List readStorageRow(UUID uuid) { - return backend.user(uuid).readRow(backend.storageType()); - } + private List readStorageRow(UUID uuid) { return backend.user(uuid).readRow(backend.storageType()); } private DataValue find(List row, String key, DataValue defaultValue) { - if (row != null) { - for (Column column : row) { - if (column.getName().equals(key)) return column.getValue() == null ? defaultValue : column.getValue(); - } - } + if (row != null) for (Column column : row) if (column.getName().equals(key)) return column.getValue() == null ? defaultValue : column.getValue(); return defaultValue; } 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 b7b39af9b..e6f444480 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 @@ -33,10 +33,7 @@ default HashMap completePopulation(UUID uuid, HashMap gate) {} - - /** Bind cache/legacy operations to the runtime's per-user shared barrier. */ default void bindUserGate(BiConsumer gate) {} - default void bindBackend(SqlUserBackend backend) {} default void bindLifecycle(SqlUserBackend backend, Consumer gate) { @@ -44,6 +41,13 @@ default void bindLifecycle(SqlUserBackend backend, Consumer gate) { bindBackend(backend); } + /** Publish the global and per-user lifecycle routes as one logical binding. */ + default void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer userGate) { + bindUserGate(userGate); + bindLifecycle(backend, gate); + } + default void requireBlockingAllowed() {} Set cachedUsers(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java index d6cda4f2c..01a4e131c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java @@ -27,18 +27,14 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; class SqlUserDataFacadeTest { - @Test - void legacyRowAndConvertOverridesRemainVirtualWithoutPluginAccess() { + @Test void legacyRowAndConvertOverridesRemainVirtualWithoutPluginAccess() { AdvancedCoreUser user = mock(AdvancedCoreUser.class); ArrayList row = new ArrayList<>(List.of(new Column("Points", new DataValueInt(17)))); HashMap converted = new HashMap<>(); UserData data = new UserData(user) { @Override public List getMySqlRow() { return row; } @Override public List getSQLiteRow() { return row; } - @Override public HashMap convert(List columns) { - assertSame(row, columns); - return converted; - } + @Override public HashMap convert(List columns) { assertSame(row, columns); return converted; } }; for (UserStorage storage : UserStorage.values()) { assertEquals(17, data.getInt(storage, "Points", 0, UserDataFetchMode.NO_CACHE)); @@ -48,8 +44,7 @@ void legacyRowAndConvertOverridesRemainVirtualWithoutPluginAccess() { verifyNoInteractions(user); } - @Test - void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { + @Test void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { for (UserDataFetchMode mode : UserDataFetchMode.values()) { Fixture f = new Fixture(); UserDataCache cache = mock(UserDataCache.class); @@ -71,8 +66,7 @@ void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { } } - @Test - void queuedCachedWriteDoesNotReachSqlOrCreateAnotherQueue() { + @Test void queuedCachedWriteDoesNotReachSqlOrCreateAnotherQueue() { Fixture f = new Fixture(); UserDataCache cache = mock(UserDataCache.class); when(f.user.isCached()).thenReturn(true); @@ -84,8 +78,7 @@ void queuedCachedWriteDoesNotReachSqlOrCreateAnotherQueue() { verifyNoInteractions(f.table, f.timer); } - @Test - void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { + @Test void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { Fixture f = new Fixture(); f.data.setInt("Points", 19, false, true); ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); @@ -98,14 +91,13 @@ void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { order.verify(f.manager).onChange(f.user, "Points"); } - @Test - void storageFailureDoesNotClearCacheAndSchedulerRejectionDoesNotWrite() { + @Test void storageFailureDoesNotClearCacheAndSchedulerRejectionDoesNotWrite() { Fixture f = new Fixture(); IllegalStateException failure = new IllegalStateException("delete failed"); doThrow(failure).when(f.table).deletePlayer("initial-id"); assertSame(failure, assertThrows(IllegalStateException.class, f.data::remove)); verify(f.user, never()).clearCache(); - clearInvocations(f.table); + clearInvocations(f.table, f.manager); RejectedExecutionException rejected = new RejectedExecutionException("stopped"); doThrow(rejected).when(f.timer).execute(any(Runnable.class)); assertSame(rejected, assertThrows(RejectedExecutionException.class, () -> f.data.setInt("Points", 1, false, true))); 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 62ea07a42..2ffb9830d 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 @@ -18,6 +18,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -37,7 +38,6 @@ import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; -/** Real manager/cache/queue with in-memory providers; no live database or game server. */ @Timeout(15) class SharedCacheBindingRegressionTest { @Test void noDatabaseLookupMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.NO_DB_LOOKUP); } @@ -54,7 +54,6 @@ private void cacheMiss(UserDataFetchMode mode) { assertSame(fallback, runtime.read(uuid, "Points", mode, null, fallback)); assertSame(fallback, runtime.read(uuid, "Points", mode, new HashMap<>(), fallback)); verify(backend, never()).user(any(UUID.class)); - verify(backend, never()).enumerateUsers(); verify(owner, never()).populate(any(UUID.class), any()); verify(owner, never()).queueChange(any(), any(), any()); verify(owner, never()).flush(any(), any(UserStorage.class), any()); @@ -73,7 +72,6 @@ private void cacheMiss(UserDataFetchMode mode) { assertSame(cached, runtime.read(uuid, "Points", UserDataFetchMode.CACHE_ONLY, temp, null)); assertSame(temporary, runtime.read(uuid, "Points", UserDataFetchMode.TEMP_ONLY, temp, null)); verify(backend, never()).user(any(UUID.class)); - verify(owner, never()).populate(any(), any()); } @Test void managerGetCacheAfterRuntimeStartupUsesTheSelectedWriter() throws Exception { @@ -154,8 +152,7 @@ private void cacheMiss(UserDataFetchMode mode) { fresh.processChanges(); assertEquals(12, replacement.points(fixture.uuid)); assertEquals(11, fixture.first.points(fixture.uuid)); - assertThrows(IllegalStateException.class, - () -> old.addChange(new UserDataChangeInt("Points", 99), true)); + assertThrows(IllegalStateException.class, () -> old.addChange(new UserDataChangeInt("Points", 99), true)); fixture.assertNoLegacyWrites(); runtime.close(); } @@ -168,14 +165,10 @@ private void cacheMiss(UserDataFetchMode mode) { CompletableFuture closing = runtime.closeAsync(worker::add).toCompletableFuture(); UserDataCache late = new UserDataCache(fixture.manager, fixture.uuid); fixture.manager.getUserDataCache().put(fixture.uuid, late); - assertThrows(IllegalStateException.class, - () -> late.addChange(new UserDataChangeInt("Points", 99), true)); - assertFalse(late.hasChangesToProcess()); + assertThrows(IllegalStateException.class, () -> late.addChange(new UserDataChangeInt("Points", 99), true)); worker.get(0).run(); closing.join(); - assertThrows(IllegalStateException.class, - () -> late.addChange(new UserDataChangeInt("Points", 99), true)); - assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + assertThrows(IllegalStateException.class, () -> late.addChange(new UserDataChangeInt("Points", 99), true)); } } @@ -185,9 +178,7 @@ private void cacheMiss(UserDataFetchMode mode) { cache.addChange(new UserDataChangeInt("Points", 15), true); SharedUserDataRuntime runtime = fixture.runtime(); fixture.manager.getUserDataCache().put(fixture.uuid, cache); - synchronized (cache) { - assertThrows(IllegalStateException.class, cache::processChanges); - } + synchronized (cache) { assertThrows(IllegalStateException.class, cache::processChanges); } assertTrue(cache.hasChangesToProcess()); runtime.close(); assertEquals(15, fixture.first.points(fixture.uuid)); @@ -208,30 +199,32 @@ private void cacheMiss(UserDataFetchMode mode) { } } - @Test void attachmentDuringALegacyBatchKeepsAReachableRetryableRuntime() throws Exception { + @Test void attachmentWaitsForLegacyBatchBeforePublishingTheSharedRoute() throws Exception { try (Fixture fixture = new Fixture()) { UserDataCache cache = fixture.manager.getCache(fixture.uuid); cache.addChange(new UserDataChangeInt("Points", 1), true); CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); var legacyData = cache.getUser().getUserData(); - doAnswer(call -> { entered.countDown(); await(release); return null; }) - .when(legacyData).setValues(any(HashMap.class)); - var worker = Executors.newSingleThreadExecutor(); + doAnswer(call -> { entered.countDown(); await(release); return null; }).when(legacyData).setValues(any(HashMap.class)); + var legacyWorker = Executors.newSingleThreadExecutor(); + var bindWorker = Executors.newSingleThreadExecutor(); try { - var legacy = worker.submit(cache::processChanges); + var legacy = legacyWorker.submit(cache::processChanges); await(entered); - SharedUserDataRuntime runtime = fixture.runtime(); - assertThrows(IllegalStateException.class, () -> runtime.flush(fixture.uuid)); - assertFalse(runtime.isClosed()); + var binding = bindWorker.submit(fixture::runtime); + assertThrows(TimeoutException.class, () -> binding.get(150, TimeUnit.MILLISECONDS)); release.countDown(); legacy.get(5, TimeUnit.SECONDS); + SharedUserDataRuntime runtime = binding.get(5, TimeUnit.SECONDS); cache.addChange(new UserDataChangeInt("Points", 14), true); runtime.close(); assertEquals(14, fixture.first.points(fixture.uuid)); } finally { release.countDown(); - worker.shutdownNow(); - assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + legacyWorker.shutdownNow(); + bindWorker.shutdownNow(); + assertTrue(legacyWorker.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(bindWorker.awaitTermination(5, TimeUnit.SECONDS)); } } } @@ -267,9 +260,7 @@ private static final class Fixture implements AutoCloseable { } SharedUserDataRuntime runtime() { return new SharedUserDataRuntime(first, owner); } - void assertNoLegacyWrites() { - verify(plugin.getUserManager().getUser(uuid, false).getUserData(), never()).setValues(any(HashMap.class)); - } + void assertNoLegacyWrites() { verify(plugin.getUserManager().getUser(uuid, false).getUserData(), never()).setValues(any(HashMap.class)); } public void close() { manager.getTimer().shutdownNow(); } } @@ -293,9 +284,7 @@ public List readRow(UserStorage requested) { } public boolean contains(UserStorage requested) { return rows.containsKey(uuid); } public void delete(UserStorage requested) { rows.remove(uuid); } - public void write(UserStorage requested, String key, DataValue value) { - writeValues(requested, new HashMap<>(Map.of(key, value))); - } + public void write(UserStorage requested, String key, DataValue value) { writeValues(requested, new HashMap<>(Map.of(key, value))); } public void writeValues(UserStorage requested, HashMap values) { assertEquals(type, requested); if (!open) throw new IllegalStateException("closed backend"); From 41adcc46b8014bcfca0c37c21a135eba97f842a9 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:47:03 -0600 Subject: [PATCH 17/34] Fix shared user review follow-ups --- .../api/user/usercache/UserDataCache.java | 16 +++++++-------- .../user/runtime/BukkitUserCacheOwner.java | 18 ++++++----------- .../user/runtime/SharedUserDataRuntime.java | 12 +++++------ .../SharedCacheBindingRegressionTest.java | 15 ++++---------- .../user/UserDataCachePopulationRaceTest.java | 20 +++++++++++++++++++ 5 files changed, 43 insertions(+), 38 deletions(-) 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 d9002e0b9..5bd4d54fd 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 @@ -61,7 +61,9 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue } if (change == null || cache == null || cachedChanges == null) return; cache.put(change.getKey(), change.toUserDataValue()); - if (sharedStorageWriter != null) changedAt.put(change.getKey(), ++snapshotVersion); + // Version every cache mutation, including legacy work before a shared writer + // is attached, so an overlapping storage refresh cannot overwrite it. + changedAt.put(change.getKey(), ++snapshotVersion); if (queue) { cachedChanges.add(change); if (!scheduled) scheduleChanges(); @@ -148,14 +150,10 @@ public void dump() { public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } - public synchronized void awaitLegacyBatchesBeforeSharedBinding() { - if (sharedStorageWriter != null) return; - while (inFlightBatches > 0) { - try { wait(); } - catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - throw new IllegalStateException("Interrupted while draining legacy user batch", interrupted); - } + /** Fail-fast preflight: constructors must never wait for an old provider batch. */ + public synchronized void ensureNoLegacyBatchForSharedBinding() { + if (sharedStorageWriter == null && inFlightBatches != 0) { + throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); } } 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 84b8d3f86..8e9724e52 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 @@ -66,11 +66,10 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer new UserDataCache(manager, uuid)); + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); } @Override - public HashMap completePopulation(UUID uuid, HashMap values, - PopulationToken token) { - if (!(token instanceof CachePopulation expected) || !uuid.equals(expected.uuid())) { - throw new IllegalArgumentException("Population token does not belong to this user"); - } + public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { + if (!(token instanceof CachePopulation expected) || !uuid.equals(expected.uuid())) throw new IllegalArgumentException("Population token does not belong to this user"); AtomicReference> populated = new AtomicReference<>(); manager.getUserDataCache().compute(uuid, (ignored, current) -> { if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); @@ -210,7 +205,6 @@ public void remove(UUID uuid) { @Override public void shutdown() { - // Keep the rejecting shared route installed until the manager itself is disposed. if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); 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 7e9890f33..6e4b1e6b9 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 @@ -41,8 +41,7 @@ public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) cacheOwner.bindLifecycle(backend, lifecycleGate, perUserGate); } - public DataValue read(UUID uuid, String key, UserDataFetchMode mode, - HashMap temporaryCache, DataValue defaultValue) { + public DataValue read(UUID uuid, String key, UserDataFetchMode mode, HashMap temporaryCache, DataValue defaultValue) { Objects.requireNonNull(uuid, "uuid"); return userAccess(uuid, () -> { Objects.requireNonNull(mode, "mode"); @@ -84,12 +83,13 @@ public int startupForEach(BiConsumer> consumer, return storageAccess(() -> { Objects.requireNonNull(consumer, "consumer"); int[] count = { 0 }; - backend.forEachUser(uuid -> userAccess(uuid, () -> { - HashMap values = populateCache ? populateInternal(uuid) : SqlUserDataAccess.convert(readStorageRow(uuid)); + backend.forEachUser(uuid -> { + HashMap values = userAccess(uuid, () -> populateCache ? populateInternal(uuid) : SqlUserDataAccess.convert(readStorageRow(uuid))); + // External callbacks run after releasing the per-user read lock so they + // may safely remove or otherwise exclusively mutate this user. consumer.accept(uuid, values); count[0]++; - return null; - })); + }); return count[0]; }); } 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 2ffb9830d..3717b38b0 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 @@ -18,7 +18,6 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; @@ -199,7 +198,7 @@ private void cacheMiss(UserDataFetchMode mode) { } } - @Test void attachmentWaitsForLegacyBatchBeforePublishingTheSharedRoute() throws Exception { + @Test void activeLegacyBatchRejectsAttachmentWithoutPublishingAndRetrySucceeds() throws Exception { try (Fixture fixture = new Fixture()) { UserDataCache cache = fixture.manager.getCache(fixture.uuid); cache.addChange(new UserDataChangeInt("Points", 1), true); @@ -207,31 +206,25 @@ private void cacheMiss(UserDataFetchMode mode) { var legacyData = cache.getUser().getUserData(); doAnswer(call -> { entered.countDown(); await(release); return null; }).when(legacyData).setValues(any(HashMap.class)); var legacyWorker = Executors.newSingleThreadExecutor(); - var bindWorker = Executors.newSingleThreadExecutor(); try { var legacy = legacyWorker.submit(cache::processChanges); await(entered); - var binding = bindWorker.submit(fixture::runtime); - assertThrows(TimeoutException.class, () -> binding.get(150, TimeUnit.MILLISECONDS)); + assertThrows(IllegalStateException.class, fixture::runtime); release.countDown(); legacy.get(5, TimeUnit.SECONDS); - SharedUserDataRuntime runtime = binding.get(5, TimeUnit.SECONDS); + SharedUserDataRuntime runtime = fixture.runtime(); cache.addChange(new UserDataChangeInt("Points", 14), true); runtime.close(); assertEquals(14, fixture.first.points(fixture.uuid)); } finally { release.countDown(); legacyWorker.shutdownNow(); - bindWorker.shutdownNow(); assertTrue(legacyWorker.awaitTermination(5, TimeUnit.SECONDS)); - assertTrue(bindWorker.awaitTermination(5, TimeUnit.SECONDS)); } } } - private static void await(CountDownLatch latch) throws InterruptedException { - assertTrue(latch.await(5, TimeUnit.SECONDS), "batch did not reach the expected boundary"); - } + private static void await(CountDownLatch latch) throws InterruptedException { assertTrue(latch.await(5, TimeUnit.SECONDS)); } private static final class Fixture implements AutoCloseable { final UUID uuid = UUID.randomUUID(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java index d1c40e605..5b20cbbe4 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java @@ -35,4 +35,24 @@ void storageRefreshCannotOverwriteANewerQueuedCacheValue() { assertEquals(7, cache.getCache().get("Points").getInt()); timer.shutdownNow(); } + + @Test + void unboundMutationVersionPreventsAnOlderRefreshFromWinning() { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + HashMap initial = new HashMap<>(); + initial.put("Points", new DataValueInt(1)); + cache.updateCache(initial); + long token = cache.getSharedSnapshotVersion(); + cache.addChange(new UserDataChangeInt("Points", 7), false); + HashMap staleStorage = new HashMap<>(); + staleStorage.put("Points", new DataValueInt(1)); + cache.updateSharedSnapshot(staleStorage, token); + assertEquals(7, cache.getCache().get("Points").getInt()); + timer.shutdownNow(); + } } From de7233950fbdc4232ce693ba8d241acf4bf1a7c6 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 13:56:01 -0600 Subject: [PATCH 18/34] Close shared user lifecycle race windows --- .../api/user/usercache/UserDataCache.java | 43 ++++++++--- .../api/user/usercache/UserDataManager.java | 75 ++++++++++++++++++- .../user/runtime/BukkitUserCacheOwner.java | 68 +++++++---------- .../user/SharedBindingAdmissionTest.java | 34 +++++++++ .../SharedCacheCleanupPrimaryThreadTest.java | 61 +++++++++++++++ .../user/UserDataCachePopulationRaceTest.java | 20 +++++ 6 files changed, 244 insertions(+), 57 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java 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 5bd4d54fd..2fd17fca2 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 @@ -61,8 +61,6 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue } if (change == null || cache == null || cachedChanges == null) return; cache.put(change.getKey(), change.toUserDataValue()); - // Version every cache mutation, including legacy work before a shared writer - // is attached, so an overlapping storage refresh cannot overwrite it. changedAt.put(change.getKey(), ++snapshotVersion); if (queue) { cachedChanges.add(change); @@ -105,6 +103,11 @@ public UserDataCache cache() { } public void clearCache() { + if (manager != null && manager.deferSharedStorageWork(this::clearCacheNow)) return; + clearCacheNow(); + } + + private void clearCacheNow() { initializeSharedStorage(); Consumer gate; synchronized (this) { @@ -118,7 +121,14 @@ public void clearCache() { gate.accept(() -> { processChanges(); synchronized (this) { if (cache != null) { cache.clear(); recordSnapshotReplacement(); } } }); } - public void clearChanges() { if (hasChangesToProcess()) processChanges(); } + public void clearChanges() { + if (!hasChangesToProcess()) return; + if (manager != null && manager.deferSharedStorageWork(this::clearChangesNow)) return; + clearChangesNow(); + } + + private void clearChangesNow() { if (hasChangesToProcess()) processChanges(); } + public void displayCache() { manager.getPlugin().devDebug(displayCacheStringList().toString()); } public synchronized ArrayList displayCacheStringList() { ArrayList list = new ArrayList<>(); @@ -133,6 +143,11 @@ public synchronized ArrayList displayCacheStringList() { } public void dump() { + if (manager != null && manager.deferSharedStorageWork(this::dumpNow)) return; + dumpNow(); + } + + private void dumpNow() { while (true) { processChanges(); synchronized (this) { @@ -150,11 +165,8 @@ public void dump() { public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } - /** Fail-fast preflight: constructors must never wait for an old provider batch. */ public synchronized void ensureNoLegacyBatchForSharedBinding() { - if (sharedStorageWriter == null && inFlightBatches != 0) { - throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); - } + if (sharedStorageWriter == null && inFlightBatches != 0) throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); } public synchronized void configureSharedStorage(Consumer> writer, Consumer gate) { @@ -181,6 +193,7 @@ private void processChangesInternal(boolean admitted) { Consumer> writer = null; Consumer gate; ArrayList changes = new ArrayList<>(); + boolean legacyAdmission = false; synchronized (this) { gate = admitted ? null : sharedFlushGate; if (gate == null) { @@ -194,6 +207,10 @@ private void processChangesInternal(boolean admitted) { } currentUuid = uuid; if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) return; + if (writer == null && manager != null) { + manager.beginLegacyCacheBatch(); + legacyAdmission = true; + } UserDataChange change; while ((change = cachedChanges.poll()) != null) changes.add(change); inFlightValues.clear(); @@ -202,6 +219,7 @@ private void processChangesInternal(boolean admitted) { } catch (RuntimeException | Error preparationFailure) { inFlightValues.clear(); requeueChanges(changes); + if (legacyAdmission) manager.endLegacyCacheBatch(); throw preparationFailure; } inFlightBatches++; @@ -223,7 +241,10 @@ private void processChangesInternal(boolean admitted) { } catch (RuntimeException | Error e) { if (!persisted) requeueChanges(changes); throw e; - } finally { finishInFlightBatch(); } + } finally { + finishInFlightBatch(); + if (legacyAdmission) manager.endLegacyCacheBatch(); + } } private synchronized void requeueChanges(ArrayList changes) { @@ -276,9 +297,7 @@ public synchronized void updateCachePreservingPending(HashMap public synchronized long getSharedSnapshotVersion() { return snapshotVersion; } public synchronized HashMap updateSharedSnapshot(HashMap values, - long expectedVersion) { - return updateSharedSnapshot(values, expectedVersion, uuid); - } + long expectedVersion) { return updateSharedSnapshot(values, expectedVersion, uuid); } private synchronized HashMap updateSharedSnapshot(HashMap values, long expectedVersion, UUID expectedUuid) { @@ -286,7 +305,7 @@ private synchronized HashMap updateSharedSnapshot(HashMap snapshotVersion) throw new IllegalArgumentException("Invalid cache snapshot version"); if (replacementVersion > expectedVersion) return new HashMap<>(cache); HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); - changedAt.forEach((key, version) -> { if (version > expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); + changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; recordSnapshotReplacement(); return new HashMap<>(cache); 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 c571f7a06..c20440d40 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 @@ -5,6 +5,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -38,6 +39,9 @@ public class UserDataManager { private volatile Consumer sharedCacheInitializer; private volatile SharedSqlRoute sharedSqlRoute; + private final Object sharedBindingAdmission = new Object(); + private boolean sharedBindingTransition; + private int legacyBatches; private record SharedSqlRoute(SqlUserBackend backend, BiConsumer gate) { SharedSqlRoute { @@ -58,13 +62,52 @@ public final synchronized void bindSharedCacheInitializer(Consumer initializer = sharedCacheInitializer; if (initializer != null) { - if (Thread.holdsLock(cache)) { - throw new IllegalStateException("Cannot attach shared storage while holding the cache monitor"); - } + if (Thread.holdsLock(cache)) throw new IllegalStateException("Cannot attach shared storage while holding the cache monitor"); initializer.accept(cache); } } + /** + * Close legacy-batch admission before a shared route is published. This is a + * fail-fast transition: constructors never wait for an old provider write. + */ + public final void beginSharedBindingTransition() { + synchronized (sharedBindingAdmission) { + if (sharedBindingTransition) throw new IllegalStateException("Shared user binding is already in progress"); + if (sharedSqlRoute != null) throw new IllegalStateException("Shared SQL backend is already bound"); + sharedBindingTransition = true; + if (legacyBatches != 0) { + sharedBindingTransition = false; + throw new IllegalStateException("Cannot attach shared storage during an active legacy batch"); + } + } + } + + public final void endSharedBindingTransition() { + synchronized (sharedBindingAdmission) { + sharedBindingTransition = false; + sharedBindingAdmission.notifyAll(); + } + } + + /** Admission used by UserDataCache before it selects the legacy provider. */ + final void beginLegacyCacheBatch() { + synchronized (sharedBindingAdmission) { + if (sharedBindingTransition || sharedSqlRoute != null) { + throw new IllegalStateException("Legacy user storage is retired by the shared runtime"); + } + legacyBatches++; + } + } + + final void endLegacyCacheBatch() { + synchronized (sharedBindingAdmission) { + if (legacyBatches <= 0) throw new IllegalStateException("Legacy user batch admission is unbalanced"); + legacyBatches--; + sharedBindingAdmission.notifyAll(); + } + } + /** Publish backend and per-user lifecycle admission as one volatile immutable route. */ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate) { sharedSqlRoute = new SharedSqlRoute(backend, gate); @@ -152,7 +195,24 @@ public void cacheUser(UUID uuid, String playerName) { public void cacheUserIfNeeded(UUID uuid) { if (!userDataCache.containsKey(uuid)) cacheUser(uuid); } + /** + * Shared SQL writes are worker-only. Existing synchronous Bukkit clear/remove + * entry points hand the complete flush-and-remove sequence to the cache worker. + */ + boolean deferSharedStorageWork(Runnable task) { + Objects.requireNonNull(task, "task"); + if (!hasSharedSqlBackend() || Bukkit.getServer() == null || !Bukkit.isPrimaryThread()) return false; + try { timer.execute(task); } + catch (RejectedExecutionException rejected) { if (plugin != null) plugin.debug(rejected); } + return true; + } + public void clearCache() { + if (deferSharedStorageWork(this::clearCacheNow)) return; + clearCacheNow(); + } + + private void clearCacheNow() { plugin.debug("Clearing cache: " + userDataCache.keySet().size()); for (UserDataCache c : userDataCache.values()) { c.clearCache(); c.dump(); } userDataCache.clear(); @@ -192,9 +252,16 @@ private void loadKeys() { } public void removeCache(UUID uuid, String playerName) { + UUID resolved = uuid; if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { - uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); + resolved = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } + UUID target = resolved; + if (deferSharedStorageWork(() -> removeCacheNow(target))) return; + removeCacheNow(target); + } + + private void removeCacheNow(UUID uuid) { UserDataCache cache = getCache(uuid); if (cache != null) cache.clearCache(); userDataCache.remove(uuid); 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 8e9724e52..56bf74f45 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 @@ -43,15 +43,13 @@ public BukkitUserCacheOwner(UserDataManager manager) { }; } - @Override - public synchronized void bindFlushGate(Consumer gate) { + @Override public synchronized void bindFlushGate(Consumer gate) { Objects.requireNonNull(gate, "gate"); if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); flushGate = gate; } - @Override - public synchronized void bindUserGate(BiConsumer gate) { + @Override public synchronized void bindUserGate(BiConsumer gate) { Objects.requireNonNull(gate, "gate"); if (userGate != null && userGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); userGate = gate; @@ -66,27 +64,27 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { + @Override public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { BiConsumer existing = userGate; if (existing == null) existing = (uuid, operation) -> gate.accept(operation); bindLifecycle(backend, gate, existing); } - @Override - public synchronized void bindBackend(SqlUserBackend backend) { + @Override public synchronized void bindBackend(SqlUserBackend backend) { Objects.requireNonNull(backend, "backend"); manager.bindSharedCacheInitializer(cacheInitializer); this.backend = backend; @@ -117,24 +115,19 @@ private void bind(UserDataCache cache, UUID uuid) { } } - @Override - public void requireBlockingAllowed() { - if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { - throw new IllegalStateException("Shared user storage must run on a worker; use closeAsync for shutdown"); - } + @Override public void requireBlockingAllowed() { + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) throw new IllegalStateException("Shared user storage must run on a worker; use closeAsync for shutdown"); } @Override public boolean isCached(UUID uuid) { return manager.isCached(uuid); } - @Override - public DataValue getIfPresent(UUID uuid, String key) { + @Override public DataValue getIfPresent(UUID uuid, String key) { UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache == null) return null; synchronized (cache) { return cache.getCache() == null ? null : cache.getCache().get(key); } } - @Override - public void populate(UUID uuid, HashMap values) { + @Override public void populate(UUID uuid, HashMap values) { UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); cache.updateCachePreservingPending(values); @@ -142,15 +135,13 @@ public void populate(UUID uuid, HashMap values) { private record CachePopulation(UUID uuid, UserDataCache cache, long version) implements PopulationToken {} - @Override - public PopulationToken beginPopulation(UUID uuid) { + @Override public PopulationToken beginPopulation(UUID uuid) { UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); } - @Override - public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { + @Override public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { if (!(token instanceof CachePopulation expected) || !uuid.equals(expected.uuid())) throw new IllegalArgumentException("Population token does not belong to this user"); AtomicReference> populated = new AtomicReference<>(); manager.getUserDataCache().compute(uuid, (ignored, current) -> { @@ -161,8 +152,7 @@ public HashMap completePopulation(UUID uuid, HashMap cachedUsers() { return new HashSet<>(manager.getUserDataCache().keySet()); } - @Override - public void remove(UUID uuid) { + @Override public void remove(UUID uuid) { UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache != null) { cache.retireAfterSharedFlush(); @@ -203,8 +190,7 @@ public void remove(UUID uuid) { @Override public void clearAfterFlush() { for (UUID uuid : cachedUsers()) remove(uuid); } - @Override - public void shutdown() { + @Override public void shutdown() { if (manager.getTimer() instanceof ScheduledThreadPoolExecutor timer) { timer.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); timer.setContinueExistingPeriodicTasksAfterShutdownPolicy(false); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java new file mode 100644 index 000000000..defddcf51 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedBindingAdmissionTest.java @@ -0,0 +1,34 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; + +import java.util.UUID; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; + +class SharedBindingAdmissionTest { + @Test + void bindingTransitionBlocksAnUnmappedCacheFromStartingANewLegacyBatch() { + UserDataManager manager = new UserDataManager(mock(AdvancedCorePlugin.class)); + try { + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + cache.addChange(new UserDataChangeInt("Points", 7), true); + manager.beginSharedBindingTransition(); + try { + assertThrows(IllegalStateException.class, cache::processChanges); + assertTrue(cache.hasChangesToProcess()); + } finally { + manager.endSharedBindingTransition(); + } + } finally { + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java new file mode 100644 index 000000000..a1eca4943 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -0,0 +1,61 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class SharedCacheCleanupPrimaryThreadTest { + @Test + void sharedCacheClearMovesTheWholeFlushAndRemovalSequenceOffThePrimaryThread() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + when(backend.storageType()).thenReturn(UserStorage.SQLITE); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.clearCache(); + verify(worker).execute(task.capture()); + verify(cache, never()).clearCache(); + assertTrue(manager.getUserDataCache().containsKey(uuid)); + task.getValue().run(); + verify(cache).clearCache(); + verify(cache).dump(); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java index 5b20cbbe4..f9954f599 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java @@ -55,4 +55,24 @@ void unboundMutationVersionPreventsAnOlderRefreshFromWinning() { assertEquals(7, cache.getCache().get("Points").getInt()); timer.shutdownNow(); } + + @Test + void directMutationAtThePopulationTokenIsStillPreserved() { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + UserDataCache cache = new UserDataCache(manager, UUID.randomUUID()); + HashMap initial = new HashMap<>(); + initial.put("Points", new DataValueInt(1)); + cache.updateCache(initial); + cache.addChange(new UserDataChangeInt("Points", 7), false); + long tokenAfterDirectMutation = cache.getSharedSnapshotVersion(); + HashMap staleStorage = new HashMap<>(); + staleStorage.put("Points", new DataValueInt(1)); + cache.updateSharedSnapshot(staleStorage, tokenAfterDirectMutation); + assertEquals(7, cache.getCache().get("Points").getInt()); + timer.shutdownNow(); + } } From 9c160c5f37116ff0d3cd2c154ba3cd0e7acb1d20 Mon Sep 17 00:00:00 2001 From: Ben Date: Sun, 13 Sep 2026 14:06:08 -0600 Subject: [PATCH 19/34] Fix backend replacement routing races --- .../api/user/usercache/UserDataManager.java | 20 +++- .../user/runtime/BukkitUserCacheOwner.java | 21 ++-- .../user/SharedRouteReplacementRaceTest.java | 111 ++++++++++++++++++ 3 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java 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 c20440d40..733b90896 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 @@ -126,16 +126,26 @@ public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { public final boolean hasSharedSqlBackend() { return sharedSqlRoute != null; } - /** Route one complete legacy SQL operation through one immutable backend/gate snapshot. */ + /** + * Route one complete legacy SQL operation through lifecycle admission. The gate + * is captured only to enter the current runtime; the backend is resolved after + * admission so a concurrent replacement cannot leave this call using the closed + * provider that was current before it waited. + */ public final T withSharedSqlBackend(UUID uuid, BiFunction operation) { Objects.requireNonNull(uuid, "uuid"); Objects.requireNonNull(operation, "operation"); - SharedSqlRoute route = sharedSqlRoute; - if (route == null) throw new IllegalStateException("Shared SQL backend is not bound"); + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null) throw new IllegalStateException("Shared SQL backend is not bound"); AtomicReference result = new AtomicReference<>(); - route.gate().accept(uuid, () -> { - SqlUserBackend selected = route.backend(); + admission.gate().accept(uuid, () -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (current.gate() != admission.gate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for admission"); + } + SqlUserBackend selected = current.backend(); if (!selected.isOpen()) throw new IllegalStateException("Shared SQL backend is unavailable"); result.set(operation.apply(selected.storageType(), selected.user(uuid))); }); 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 56bf74f45..848716dbe 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 @@ -67,7 +67,6 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer cacheGate(UUID uuid) { } private void bind(UserDataCache cache, UUID uuid) { - SqlUserBackend selected = backend; - if (selected != null) { - cache.configureSharedStorage(values -> { - requireBlockingAllowed(); - selected.user(uuid).writeValues(selected.storageType(), values); - }, cacheGate(uuid)); - } + if (backend == null) return; + // Do not capture the provider. Detached caches can outlive a replacement + // boundary before they are inserted into the manager map. Their writer is + // always invoked under the stable per-user lifecycle gate, then resolves + // whichever backend is current after that admission. + cache.configureSharedStorage(values -> { + requireBlockingAllowed(); + SqlUserBackend selected = backend; + if (selected == null || !selected.isOpen()) { + throw new IllegalStateException("Shared SQL backend is unavailable"); + } + selected.user(uuid).writeValues(selected.storageType(), values); + }, cacheGate(uuid)); } @Override public void requireBlockingAllowed() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java new file mode 100644 index 000000000..8219e9184 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java @@ -0,0 +1,111 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; + +class SharedRouteReplacementRaceTest { + @Test + void legacyOperationResolvesTheBackendAfterLifecycleAdmission() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + SqlUserBackend oldBackend = mock(SqlUserBackend.class); + SqlUserBackend newBackend = mock(SqlUserBackend.class); + SqlUserStorage newStorage = mock(SqlUserStorage.class); + when(newBackend.isOpen()).thenReturn(true); + when(newBackend.storageType()).thenReturn(UserStorage.SQLITE); + when(newBackend.user(uuid)).thenReturn(newStorage); + + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + BiConsumer gate = (id, operation) -> { + entered.countDown(); + await(release); + operation.run(); + }; + manager.bindSharedSqlBackend(oldBackend, gate); + + var worker = Executors.newSingleThreadExecutor(); + try { + var result = worker.submit(() -> manager.withSharedSqlBackend(uuid, (type, storage) -> { + storage.contains(type); + return type; + })); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + manager.bindSharedSqlBackend(newBackend, gate); + release.countDown(); + assertEquals(UserStorage.SQLITE, result.get(5, TimeUnit.SECONDS)); + verify(newBackend).user(uuid); + verify(newStorage).contains(UserStorage.SQLITE); + verify(oldBackend, never()).user(any()); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void detachedCacheWriterFollowsBackendReplacement() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + BukkitUserCacheOwner owner = new BukkitUserCacheOwner(manager); + SqlUserBackend oldBackend = mock(SqlUserBackend.class); + SqlUserBackend newBackend = mock(SqlUserBackend.class); + SqlUserStorage oldStorage = mock(SqlUserStorage.class); + SqlUserStorage newStorage = mock(SqlUserStorage.class); + when(oldBackend.isOpen()).thenReturn(true); + when(oldBackend.storageType()).thenReturn(UserStorage.MYSQL); + when(oldBackend.user(uuid)).thenReturn(oldStorage); + when(newBackend.isOpen()).thenReturn(true); + when(newBackend.storageType()).thenReturn(UserStorage.SQLITE); + when(newBackend.user(uuid)).thenReturn(newStorage); + + BiConsumer perUser = (id, operation) -> operation.run(); + owner.bindLifecycle(oldBackend, Runnable::run, perUser); + UserDataCache detached = new UserDataCache(manager, uuid); + detached.addChange(new UserDataChangeInt("Points", 1), false); + owner.bindBackend(newBackend); + manager.getUserDataCache().put(uuid, detached); + detached.addChange(new UserDataChangeInt("Points", 2), true); + detached.processChanges(); + + verify(newStorage).writeValues(any(UserStorage.class), any(HashMap.class)); + verify(oldStorage, never()).writeValues(any(UserStorage.class), any(HashMap.class)); + manager.getTimer().shutdownNow(); + } + + private static void await(CountDownLatch latch) { + try { + if (!latch.await(5, TimeUnit.SECONDS)) throw new AssertionError("timed out waiting for lifecycle test latch"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + } +} From b4225f5d2c93c3f5af3d35d18abd94b137327738 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:50:31 -0600 Subject: [PATCH 20/34] Close shared user storage transition gaps --- .../api/user/usercache/UserDataManager.java | 67 +++++++++++++- .../user/storage/BukkitSqlUserStorage.java | 76 ++++++++-------- .../SharedCacheCleanupPrimaryThreadTest.java | 65 ++++++++++++++ .../user/SharedLegacyBackendRoutingTest.java | 87 +++++++++++++++++++ 4 files changed, 256 insertions(+), 39 deletions(-) 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 733b90896..5f9ecca23 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 @@ -12,6 +12,7 @@ import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; +import java.util.function.Supplier; import org.bukkit.Bukkit; import org.bukkit.entity.Player; @@ -39,6 +40,7 @@ public class UserDataManager { private volatile Consumer sharedCacheInitializer; private volatile SharedSqlRoute sharedSqlRoute; + private final AtomicReference lastDeferredStorageFailure = new AtomicReference<>(); private final Object sharedBindingAdmission = new Object(); private boolean sharedBindingTransition; private int legacyBatches; @@ -152,6 +154,44 @@ public final T withSharedSqlBackend(UUID uuid, return result.get(); } + /** + * Atomically chooses the shared route or admits one complete legacy provider + * call. A binding transition cannot publish a replacement while the legacy + * call is active. + */ + public final T withSharedSqlBackendOrLegacy(UUID uuid, + BiFunction sharedOperation, Supplier legacyOperation) { + Objects.requireNonNull(uuid, "uuid"); + return withSharedSqlBackendOrLegacy(() -> uuid, sharedOperation, legacyOperation); + } + + /** + * Supplier overload keeps legacy string identifiers opaque until a shared route + * is actually selected. + */ + public final T withSharedSqlBackendOrLegacy(Supplier uuid, + BiFunction sharedOperation, Supplier legacyOperation) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(sharedOperation, "sharedOperation"); + Objects.requireNonNull(legacyOperation, "legacyOperation"); + boolean legacy; + synchronized (sharedBindingAdmission) { + legacy = sharedSqlRoute == null; + if (legacy) { + if (sharedBindingTransition) { + throw new IllegalStateException("Legacy user storage is retired by the shared runtime"); + } + legacyBatches++; + } + } + if (!legacy) return withSharedSqlBackend(Objects.requireNonNull(uuid.get(), "uuid"), sharedOperation); + try { + return legacyOperation.get(); + } finally { + endLegacyCacheBatch(); + } + } + public UserDataManager(AdvancedCorePlugin plugin) { this.plugin = plugin; userDataCache = new ConcurrentHashMap<>(); @@ -212,11 +252,34 @@ public void cacheUser(UUID uuid, String playerName) { boolean deferSharedStorageWork(Runnable task) { Objects.requireNonNull(task, "task"); if (!hasSharedSqlBackend() || Bukkit.getServer() == null || !Bukkit.isPrimaryThread()) return false; - try { timer.execute(task); } - catch (RejectedExecutionException rejected) { if (plugin != null) plugin.debug(rejected); } + try { + timer.execute(() -> { + lastDeferredStorageFailure.set(null); + try { + task.run(); + } catch (RuntimeException | Error failure) { + reportDeferredStorageFailure(failure); + throw failure; + } + }); + } + catch (RejectedExecutionException rejected) { + reportDeferredStorageFailure(rejected); + throw rejected; + } return true; } + private void reportDeferredStorageFailure(Throwable failure) { + lastDeferredStorageFailure.set(failure); + if (plugin != null && plugin.getLogger() != null) { + plugin.getLogger().log(java.util.logging.Level.SEVERE, "Deferred user-cache cleanup failed", failure); + } + } + + /** Last asynchronous cache-cleanup failure, retained for diagnosis and recovery. */ + public Throwable getLastDeferredStorageFailure() { return lastDeferredStorageFailure.get(); } + public void clearCache() { if (deferSharedStorageWork(this::clearCacheNow)) return; clearCacheNow(); 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 802964469..c69891764 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 @@ -30,78 +30,80 @@ public BukkitSqlUserStorage(Supplier plugin, Supplier T shared(UserDataManager manager, BiFunction operation) { - return manager.withSharedSqlBackend(userId(), operation); + private T routed(BiFunction sharedOperation, + Supplier legacyOperation) { + UserDataManager manager = dataManager(); + return manager == null ? legacyOperation.get() + : manager.withSharedSqlBackendOrLegacy(this::userId, sharedOperation, legacyOperation); } @Override public List readRow(UserStorage storage) { Objects.requireNonNull(storage, "storage"); - UserDataManager manager = sharedManager(); - if (manager != null) return shared(manager, (type, target) -> target.readRow(type)); - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) return owner().getMysql().getExact(uuid.get()); - return owner().getSQLiteUserTable().getExact(primary()); + return routed((type, target) -> target.readRow(type), () -> storage == UserStorage.MYSQL + ? owner().getMysql().getExact(uuid.get()) : owner().getSQLiteUserTable().getExact(primary())); } @Override public boolean contains(UserStorage storage) { Objects.requireNonNull(storage, "storage"); - UserDataManager manager = sharedManager(); - if (manager != null) return shared(manager, (type, target) -> target.contains(type)); - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) return owner().getMysql().containsKey(uuid.get()); - return owner().getSQLiteUserTable().containsKey(uuid.get()); + return routed((type, target) -> target.contains(type), () -> storage == UserStorage.MYSQL + ? owner().getMysql().containsKey(uuid.get()) : owner().getSQLiteUserTable().containsKey(uuid.get())); } @Override public void delete(UserStorage storage) { Objects.requireNonNull(storage, "storage"); - UserDataManager manager = sharedManager(); - if (manager != null) { shared(manager, (type, target) -> { target.delete(type); return null; }); return; } - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) owner().getMysql().deletePlayer(uuid.get()); - else owner().getSQLiteUserTable().delete(primary()); + routed((type, target) -> { target.delete(type); return null; }, () -> { + if (storage == UserStorage.MYSQL) owner().getMysql().deletePlayer(uuid.get()); + else owner().getSQLiteUserTable().delete(primary()); + return null; + }); } @Override public void write(UserStorage storage, String key, DataValue value) { Objects.requireNonNull(storage, "storage"); - UserDataManager manager = sharedManager(); - if (manager != null) { shared(manager, (type, target) -> { target.write(type, key, value); return null; }); return; } - if (Objects.requireNonNull(storage, "storage") == UserStorage.SQLITE) { - ArrayList columns = new ArrayList<>(); - Column primary = primary(); - columns.add(primary); - columns.add(new Column(key, value)); - owner().getSQLiteUserTable().update(primary, columns); - } else owner().getMysql().update(uuid.get(), key, value); + routed((type, target) -> { target.write(type, key, value); return null; }, () -> { + if (storage == UserStorage.SQLITE) { + ArrayList columns = new ArrayList<>(); + Column primary = primary(); + columns.add(primary); + columns.add(new Column(key, value)); + owner().getSQLiteUserTable().update(primary, columns); + } else owner().getMysql().update(uuid.get(), key, value); + return null; + }); } @Override public void writeValues(UserStorage storage, HashMap values) { Objects.requireNonNull(storage, "storage"); - UserDataManager manager = sharedManager(); - if (manager != null) { shared(manager, (type, target) -> { target.writeValues(type, values); return null; }); return; } - if (Objects.requireNonNull(storage, "storage") == UserStorage.MYSQL) { - if (owner().getMysql() != null) { + routed((type, target) -> { target.writeValues(type, values); return null; }, () -> { + if (storage == UserStorage.MYSQL) { + if (owner().getMysql() != null) { + ArrayList columns = new ArrayList<>(); + for (Entry entry : values.entrySet()) { + if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); + } + owner().getMysql().update(uuid.get(), columns, false); + } + } else { ArrayList columns = new ArrayList<>(); for (Entry entry : values.entrySet()) { if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); + owner().getSQLiteUserTable().update(primary(), columns); } - owner().getMysql().update(uuid.get(), columns, false); - } - } else { - ArrayList columns = new ArrayList<>(); - for (Entry entry : values.entrySet()) { - if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); - owner().getSQLiteUserTable().update(primary(), columns); } - } + return null; + }); } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index a1eca4943..2aceb4b40 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -1,6 +1,8 @@ package com.bencodez.advancedcore.tests.user; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -11,7 +13,10 @@ import java.lang.reflect.Field; import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.logging.Level; +import java.util.logging.Logger; import org.bukkit.Bukkit; import org.bukkit.Server; @@ -25,6 +30,66 @@ import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; class SharedCacheCleanupPrimaryThreadTest { + @Test + void acceptedPrimaryThreadCleanupFailureIsLoggedAndRetained() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + Logger logger = mock(Logger.class); + when(plugin.getLogger()).thenReturn(logger); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + RuntimeException failure = new IllegalStateException("flush failed"); + org.mockito.Mockito.doThrow(failure).when(cache).clearCache(); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.clearCache(); + verify(worker).execute(task.capture()); + assertSame(failure, assertThrows(RuntimeException.class, task.getValue()::run)); + assertSame(failure, manager.getLastDeferredStorageFailure()); + verify(logger).log(Level.SEVERE, "Deferred user-cache cleanup failed", failure); + assertTrue(manager.getUserDataCache().containsKey(uuid)); + } + } + + @Test + void rejectedPrimaryThreadCleanupIsReportedWithoutRunningStorageWork() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + RejectedExecutionException rejection = new RejectedExecutionException("stopped"); + org.mockito.Mockito.doThrow(rejection).when(worker).execute(any(Runnable.class)); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertSame(rejection, assertThrows(RejectedExecutionException.class, manager::clearCache)); + verify(cache, never()).clearCache(); + assertTrue(manager.getUserDataCache().containsKey(uuid)); + } + } + @Test void sharedCacheClearMovesTheWholeFlushAndRemovalSequenceOffThePrimaryThread() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java index ba466444e..7d6e66dc7 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java @@ -2,12 +2,22 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.util.HashMap; import java.util.List; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; @@ -18,10 +28,87 @@ import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; import com.bencodez.simpleapi.sql.Column; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; class SharedLegacyBackendRoutingTest { + @Test + void directLegacyCallCannotSlipIntoAnActiveBindingTransition() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL legacy = mock(MySQL.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getMysql()).thenReturn(legacy); + UUID uuid = UUID.randomUUID(); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, uuid::toString); + manager.beginSharedBindingTransition(); + try { + assertThrows(IllegalStateException.class, () -> adapter.contains(UserStorage.MYSQL)); + verifyNoInteractions(legacy); + } finally { + manager.endSharedBindingTransition(); + manager.getTimer().shutdownNow(); + } + } + + @Test + void managerPresentLegacyRoutePreservesOpaqueStringIdentifiers() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL legacy = mock(MySQL.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getMysql()).thenReturn(legacy); + when(legacy.containsKey("legacy-user-key")).thenReturn(true); + try { + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, () -> "legacy-user-key"); + assertTrue(adapter.contains(UserStorage.MYSQL)); + verify(legacy).containsKey("legacy-user-key"); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void directLegacyWriteIsAdmittedBeforeAReplacementCanBePublished() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + MySQL legacy = mock(MySQL.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getMysql()).thenReturn(legacy); + UUID uuid = UUID.randomUUID(); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + doAnswer(invocation -> { + entered.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + return null; + }).when(legacy).update(eq(uuid.toString()), eq("Points"), any(DataValue.class)); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, uuid::toString); + var worker = Executors.newSingleThreadExecutor(); + try { + var write = worker.submit(() -> adapter.write(UserStorage.MYSQL, "Points", new DataValueInt(1))); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertThrows(IllegalStateException.class, manager::beginSharedBindingTransition); + release.countDown(); + write.get(5, TimeUnit.SECONDS); + manager.beginSharedBindingTransition(); + manager.endSharedBindingTransition(); + verify(legacy).update(eq(uuid.toString()), eq("Points"), any(DataValue.class)); + } finally { + release.countDown(); + worker.shutdownNow(); + manager.getTimer().shutdownNow(); + } + } + @Test void legacyAdapterUsesBoundReplacementBackendRegardlessOfPluginStorageType() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); From 6882a8a748c1f2e8f410b16f6725bd0109799e49 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:02:44 -0600 Subject: [PATCH 21/34] Close shared cache lifecycle races --- .../api/user/usercache/UserDataCache.java | 12 +- .../api/user/usercache/UserDataManager.java | 80 ++++++++++--- .../user/runtime/BukkitUserCacheOwner.java | 19 +++- .../user/runtime/SharedUserDataRuntime.java | 3 +- .../core/user/runtime/UserCacheOwner.java | 6 + .../SharedCacheBindingRegressionTest.java | 106 ++++++++++++++++++ .../SharedUserLifecycleRegressionTest.java | 18 +++ 7 files changed, 224 insertions(+), 20 deletions(-) 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 2fd17fca2..1d4105b9f 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 @@ -228,6 +228,8 @@ private void processChangesInternal(boolean admitted) { } if (gate != null) { gate.accept(() -> processChangesInternal(true)); return; } boolean persisted = false; + AdvancedCoreUser changedUser = null; + String[] changedKeys = null; try { manager.getPlugin().extraDebug("Processing changes for " + currentUuid + ", Changes: " + changes.size()); AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); @@ -236,8 +238,8 @@ private void processChangesInternal(boolean admitted) { for (UserDataChange change : changes) { values.put(change.getKey(), change.toUserDataValue()); keys.add(change.getKey()); } if (!values.isEmpty()) { if (writer == null) user.getUserData().setValues(values); else writer.accept(values); } persisted = true; - manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(keys)); - for (UserDataChange change : changes) change.dump(); + changedUser = user; + changedKeys = ArrayUtils.convert(keys); } catch (RuntimeException | Error e) { if (!persisted) requeueChanges(changes); throw e; @@ -245,6 +247,12 @@ private void processChangesInternal(boolean admitted) { finishInFlightBatch(); if (legacyAdmission) manager.endLegacyCacheBatch(); } + // UserDataChanged callbacks may clear this cache. Do not expose the cache + // while its active shared batch marker is still set. + if (persisted) { + manager.getPlugin().getUserManager().onChange(changedUser, changedKeys); + for (UserDataChange change : changes) change.dump(); + } } private synchronized void requeueChanges(ArrayList changes) { 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 5f9ecca23..4e3855e71 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 @@ -45,10 +45,12 @@ public class UserDataManager { private boolean sharedBindingTransition; private int legacyBatches; - private record SharedSqlRoute(SqlUserBackend backend, BiConsumer gate) { + private record SharedSqlRoute(SqlUserBackend backend, BiConsumer gate, + BiConsumer exclusiveGate) { SharedSqlRoute { Objects.requireNonNull(backend, "backend"); Objects.requireNonNull(gate, "gate"); + Objects.requireNonNull(exclusiveGate, "exclusiveGate"); } } @@ -112,7 +114,13 @@ final void endLegacyCacheBatch() { /** Publish backend and per-user lifecycle admission as one volatile immutable route. */ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate) { - sharedSqlRoute = new SharedSqlRoute(backend, gate); + bindSharedSqlBackend(backend, gate, gate); + } + + /** Publish the shared read and exclusive per-user lifecycle admissions together. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate, + BiConsumer exclusiveGate) { + sharedSqlRoute = new SharedSqlRoute(backend, gate, exclusiveGate); } /** Compatibility overload for adapters that only need the global lifecycle gate. */ @@ -154,6 +162,44 @@ public final T withSharedSqlBackend(UUID uuid, return result.get(); } + /** + * Execute one complete legacy cache transition with exclusive per-user lifecycle + * admission. This prevents a writer that observed the old map entry from + * queuing work between its flush and removal. + */ + private void withSharedSqlBackendExclusive(UUID uuid, Runnable operation) { + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null) { + operation.run(); + return; + } + admission.exclusiveGate().accept(uuid, () -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (current.exclusiveGate() != admission.exclusiveGate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for exclusive admission"); + } + operation.run(); + }); + } + + /** Admit cache population through the same route that guards backend replacement and close. */ + private void withSharedCacheAdmission(UUID uuid, Runnable operation) { + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null) { + operation.run(); + return; + } + admission.gate().accept(uuid, () -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (current.gate() != admission.gate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for cache admission"); + } + operation.run(); + }); + } + /** * Atomically chooses the shared route or admits one complete legacy provider * call. A binding transition cannot publish a replacement while the legacy @@ -213,8 +259,12 @@ public void addKey(UserDataKey userDataKey) { @Deprecated public void cacheUser(UUID uuid) { + withSharedCacheAdmission(uuid, () -> cacheUserNow(uuid, true)); + } + + private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall) { plugin.devDebug("Caching " + uuid.toString()); - if (plugin.getOptions().getDebug().isDebug(DebugLevel.DEV)) { + if (traceDevelopmentCall && plugin.getOptions().getDebug().isDebug(DebugLevel.DEV)) { try { throw new Exception("caching here: " + uuid.toString()); } catch (Exception e) { e.printStackTrace(); } } @@ -232,15 +282,8 @@ public void cacheUser(UUID uuid, String playerName) { if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } - plugin.devDebug("Caching " + uuid.toString()); - if (userDataCache.containsKey(uuid)) { - UserDataCache data = userDataCache.get(uuid); - data.clearChanges(); - data.cache(); - } else { - UserDataCache data = new UserDataCache(this, uuid).cache(); - if (data.hasCache()) userDataCache.put(uuid, data); - } + UUID target = uuid; + withSharedCacheAdmission(target, () -> cacheUserNow(target, false)); } public void cacheUserIfNeeded(UUID uuid) { if (!userDataCache.containsKey(uuid)) cacheUser(uuid); } @@ -335,9 +378,18 @@ public void removeCache(UUID uuid, String playerName) { } private void removeCacheNow(UUID uuid) { + boolean shared = sharedSqlRoute != null; + withSharedSqlBackendExclusive(uuid, () -> removeCacheExclusively(uuid, shared)); + } + + private void removeCacheExclusively(UUID uuid, boolean shared) { UserDataCache cache = getCache(uuid); - if (cache != null) cache.clearCache(); - userDataCache.remove(uuid); + if (cache != null) { + cache.clearCache(); + if (shared) cache.retireAfterSharedFlush(); + } + if (cache == null) userDataCache.remove(uuid); + else userDataCache.remove(uuid, cache); } public void updateCacheOnline() { 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 848716dbe..4bc9589e0 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 @@ -31,6 +31,7 @@ public final class BukkitUserCacheOwner implements UserCacheOwner { private volatile SqlUserBackend backend; private volatile Consumer flushGate; private volatile BiConsumer userGate; + private volatile BiConsumer exclusiveUserGate; private final ConcurrentHashMap> cacheGates = new ConcurrentHashMap<>(); private final Consumer cacheInitializer; @@ -58,19 +59,28 @@ public BukkitUserCacheOwner(UserDataManager manager) { @Override public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate, BiConsumer perUserGate) { + bindLifecycle(backend, gate, perUserGate, perUserGate); + } + + @Override + public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer perUserGate, BiConsumer perUserExclusiveGate) { Objects.requireNonNull(backend, "backend"); Objects.requireNonNull(gate, "gate"); Objects.requireNonNull(perUserGate, "perUserGate"); + Objects.requireNonNull(perUserExclusiveGate, "perUserExclusiveGate"); if (flushGate != null && flushGate != gate) throw new IllegalStateException("Cache owner already belongs to another runtime"); if (userGate != null && userGate != perUserGate) throw new IllegalStateException("Cache owner already belongs to another runtime"); + if (exclusiveUserGate != null && exclusiveUserGate != perUserExclusiveGate) throw new IllegalStateException("Cache owner already belongs to another runtime"); manager.beginSharedBindingTransition(); try { manager.bindSharedCacheInitializer(cacheInitializer); - manager.bindSharedSqlBackend(backend, perUserGate); + manager.bindSharedSqlBackend(backend, perUserGate, perUserExclusiveGate); this.backend = backend; flushGate = gate; userGate = perUserGate; + exclusiveUserGate = perUserExclusiveGate; cacheGates.clear(); } finally { manager.endSharedBindingTransition(); @@ -80,7 +90,9 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer gate) { BiConsumer existing = userGate; if (existing == null) existing = (uuid, operation) -> gate.accept(operation); - bindLifecycle(backend, gate, existing); + BiConsumer exclusive = exclusiveUserGate; + if (exclusive == null) exclusive = existing; + bindLifecycle(backend, gate, existing, exclusive); } @Override public synchronized void bindBackend(SqlUserBackend backend) { @@ -88,7 +100,8 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer perUser = userGate; - if (perUser != null) manager.bindSharedSqlBackend(backend, perUser); + if (perUser != null) manager.bindSharedSqlBackend(backend, perUser, + exclusiveUserGate == null ? perUser : exclusiveUserGate); else if (flushGate != null) manager.bindSharedSqlBackend(backend, flushGate); } 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 6e4b1e6b9..fc1ac972c 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 @@ -38,7 +38,8 @@ public SharedUserDataRuntime(SqlUserBackend backend, UserCacheOwner cacheOwner) this.cacheOwner = Objects.requireNonNull(cacheOwner, "cacheOwner"); Consumer lifecycleGate = batch -> access(() -> { batch.run(); return null; }); BiConsumer perUserGate = (uuid, batch) -> userAccess(uuid, () -> { batch.run(); return null; }); - cacheOwner.bindLifecycle(backend, lifecycleGate, perUserGate); + BiConsumer exclusiveUserGate = (uuid, batch) -> userExclusiveAccess(uuid, () -> { batch.run(); return null; }); + cacheOwner.bindLifecycle(backend, lifecycleGate, perUserGate, exclusiveUserGate); } public DataValue read(UUID uuid, String key, UserDataFetchMode mode, HashMap temporaryCache, DataValue defaultValue) { 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 e6f444480..4ca50752d 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 @@ -48,6 +48,12 @@ default void bindLifecycle(SqlUserBackend backend, Consumer gate, bindLifecycle(backend, gate); } + /** Also supplies exclusive per-user admission for legacy cache removal. */ + default void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer userGate, BiConsumer exclusiveUserGate) { + bindLifecycle(backend, gate, userGate); + } + default void requireBlockingAllowed() {} Set cachedUsers(); 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 3717b38b0..90e8c8c46 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 @@ -43,6 +43,78 @@ class SharedCacheBindingRegressionTest { @Test void cacheOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.CACHE_ONLY); } @Test void temporaryOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.TEMP_ONLY); } + @Test void legacyCachePopulationPublishesOnlyAfterSharedAdmission() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + var data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenReturn(new HashMap<>()); + SqlUserBackend backend = mock(SqlUserBackend.class); + CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); + manager.bindSharedSqlBackend(backend, (id, operation) -> { + entered.countDown(); + try { + await(release); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + operation.run(); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var population = worker.submit(() -> manager.cacheUser(uuid, null)); + await(entered); + assertFalse(manager.containsKey(uuid), "a detached snapshot must not be published before admission"); + release.countDown(); + population.get(5, TimeUnit.SECONDS); + assertTrue(manager.containsKey(uuid)); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void cachePopulationCannotPublishAfterBackendReplacementStarts() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + CountDownLatch loading = new CountDownLatch(1), release = new CountDownLatch(1); + var data = fixture.plugin.getUserManager().getUser(fixture.uuid, false).getUserData(); + doAnswer(call -> { + loading.countDown(); + try { + await(release); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + return new HashMap(); + }).when(data).getValues(); + MemoryBackend replacement = new MemoryBackend(UserStorage.SQLITE); + var workers = Executors.newFixedThreadPool(2); + try { + var population = workers.submit(() -> fixture.manager.cacheUser(fixture.uuid, null)); + await(loading); + var replacementTask = workers.submit(() -> runtime.replaceBackend(replacement)); + assertThrows(java.util.concurrent.TimeoutException.class, + () -> replacementTask.get(200, TimeUnit.MILLISECONDS)); + release.countDown(); + population.get(5, TimeUnit.SECONDS); + replacementTask.get(5, TimeUnit.SECONDS); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertFalse(fixture.first.isOpen()); + } finally { + release.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + private void cacheMiss(UserDataFetchMode mode) { SqlUserBackend backend = mock(SqlUserBackend.class); UserCacheOwner owner = mock(UserCacheOwner.class); @@ -224,6 +296,40 @@ private void cacheMiss(UserDataFetchMode mode) { } } + @Test void legacyRemovalUsesTheExclusiveSharedAdmission() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + int[] exclusiveCalls = { 0 }; + manager.bindSharedSqlBackend(backend, + (id, operation) -> fail("legacy removal must not use shared read admission"), + (id, operation) -> { exclusiveCalls[0]++; operation.run(); }); + + manager.removeCache(uuid, null); + + assertEquals(1, exclusiveCalls[0]); + assertFalse(manager.containsKey(uuid)); + } + + @Test void legacyRemovalRetiresTheSharedCacheBeforeDetachingIt() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + + fixture.manager.removeCache(fixture.uuid, null); + + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertThrows(IllegalStateException.class, + () -> cache.addChange(new UserDataChangeInt("Points", 2), true)); + runtime.close(); + } + } + private static void await(CountDownLatch latch) throws InterruptedException { assertTrue(latch.await(5, TimeUnit.SECONDS)); } private static final class Fixture implements AutoCloseable { 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 90f4b688c..63ee37a30 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 @@ -176,6 +176,24 @@ void scheduledBatchAndNotificationAreInsideTheShutdownBarrier() throws Exception } } + @Test + void callbackCanClearTheCacheAfterItsSharedBatchCompletes() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + var userManager = fixture.plugin.getUserManager(); + doAnswer(call -> { + cache.clearCache(); + return null; + }).when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + + assertDoesNotThrow(cache::processChanges); + assertTrue(cache.getCache().isEmpty()); + + runtime.close(); + } + @Test void blockingCloseIsRejectedOnServerThreadButAsyncCloseDoesNotWaitThere() { Fixture fixture = new Fixture(); From fdb9bffebddffb29f41ce39df6301cdd90b045e8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:20:40 -0600 Subject: [PATCH 22/34] Close cache transition lifecycle gaps --- .../api/user/usercache/UserDataCache.java | 25 +++++++--- .../api/user/usercache/UserDataManager.java | 23 ++++++++-- .../user/runtime/BukkitUserCacheOwner.java | 3 ++ .../SharedCacheBindingRegressionTest.java | 46 +++++++++++++++++++ .../SharedUserLifecycleRegressionTest.java | 29 +++++++++--- 5 files changed, 109 insertions(+), 17 deletions(-) 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 1d4105b9f..a5861fe0e 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 @@ -186,9 +186,13 @@ public synchronized void retireAfterSharedFlush() { recordSnapshotReplacement(); cache = null; cachedChanges = null; uuid = null; scheduled = false; } - public void processChanges() { initializeSharedStorage(); processChangesInternal(false); } + public void processChanges() { + initializeSharedStorage(); + Runnable notification = processChangesInternal(false); + if (notification != null) notification.run(); + } - private void processChangesInternal(boolean admitted) { + private Runnable processChangesInternal(boolean admitted) { UUID currentUuid = null; Consumer> writer = null; Consumer gate; @@ -206,7 +210,7 @@ private void processChangesInternal(boolean admitted) { } } currentUuid = uuid; - if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) return; + if (currentUuid == null || cachedChanges == null || cachedChanges.isEmpty()) return null; if (writer == null && manager != null) { manager.beginLegacyCacheBatch(); legacyAdmission = true; @@ -226,7 +230,11 @@ private void processChangesInternal(boolean admitted) { if (writer != null) sharedBatchThread = Thread.currentThread(); } } - if (gate != null) { gate.accept(() -> processChangesInternal(true)); return; } + if (gate != null) { + java.util.concurrent.atomic.AtomicReference notification = new java.util.concurrent.atomic.AtomicReference<>(); + gate.accept(() -> notification.set(processChangesInternal(true))); + return notification.get(); + } boolean persisted = false; AdvancedCoreUser changedUser = null; String[] changedKeys = null; @@ -249,10 +257,13 @@ private void processChangesInternal(boolean admitted) { } // UserDataChanged callbacks may clear this cache. Do not expose the cache // while its active shared batch marker is still set. - if (persisted) { - manager.getPlugin().getUserManager().onChange(changedUser, changedKeys); + if (!persisted) return null; + AdvancedCoreUser notifyUser = changedUser; + String[] notifyKeys = changedKeys; + return () -> { + manager.getPlugin().getUserManager().onChange(notifyUser, notifyKeys); for (UserDataChange change : changes) change.dump(); - } + }; } private synchronized void requeueChanges(ArrayList changes) { 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 4e3855e71..3ff67b9ab 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 @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.Objects; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -39,6 +40,7 @@ public class UserDataManager { @Getter private ConcurrentHashMap userDataCache; private volatile Consumer sharedCacheInitializer; + private volatile Consumer sharedCacheRemovalListener; private volatile SharedSqlRoute sharedSqlRoute; private final AtomicReference lastDeferredStorageFailure = new AtomicReference<>(); private final Object sharedBindingAdmission = new Object(); @@ -63,6 +65,15 @@ public final synchronized void bindSharedCacheInitializer(Consumer listener) { + Objects.requireNonNull(listener, "listener"); + if (sharedCacheRemovalListener != null && sharedCacheRemovalListener != listener) { + throw new IllegalStateException("User data manager already belongs to another shared runtime"); + } + sharedCacheRemovalListener = listener; + } + void initializeSharedCache(UserDataCache cache) { Consumer initializer = sharedCacheInitializer; if (initializer != null) { @@ -187,7 +198,9 @@ private void withSharedSqlBackendExclusive(UUID uuid, Runnable operation) { private void withSharedCacheAdmission(UUID uuid, Runnable operation) { SharedSqlRoute admission = sharedSqlRoute; if (admission == null) { - operation.run(); + beginLegacyCacheBatch(); + try { operation.run(); } + finally { endLegacyCacheBatch(); } return; } admission.gate().accept(uuid, () -> { @@ -330,8 +343,11 @@ public void clearCache() { private void clearCacheNow() { plugin.debug("Clearing cache: " + userDataCache.keySet().size()); + Set removed = new java.util.HashSet<>(userDataCache.keySet()); for (UserDataCache c : userDataCache.values()) { c.clearCache(); c.dump(); } userDataCache.clear(); + Consumer listener = sharedCacheRemovalListener; + if (listener != null) removed.forEach(listener); } public void clearCacheBasic() { @@ -388,8 +404,9 @@ private void removeCacheExclusively(UUID uuid, boolean shared) { cache.clearCache(); if (shared) cache.retireAfterSharedFlush(); } - if (cache == null) userDataCache.remove(uuid); - else userDataCache.remove(uuid, cache); + boolean removed = cache == null ? userDataCache.remove(uuid) != null : userDataCache.remove(uuid, cache); + Consumer listener = sharedCacheRemovalListener; + if (removed && listener != null) listener.accept(uuid); } public void updateCacheOnline() { 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 4bc9589e0..cceea5778 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 @@ -33,6 +33,7 @@ public final class BukkitUserCacheOwner implements UserCacheOwner { private volatile BiConsumer userGate; private volatile BiConsumer exclusiveUserGate; private final ConcurrentHashMap> cacheGates = new ConcurrentHashMap<>(); + private final Consumer cacheRemovalListener = cacheGates::remove; private final Consumer cacheInitializer; public BukkitUserCacheOwner(UserDataManager manager) { @@ -76,6 +77,7 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer perUser = userGate; if (perUser != null) manager.bindSharedSqlBackend(backend, perUser, 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 90e8c8c46..b6db3d15d 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 @@ -78,6 +78,35 @@ class SharedCacheBindingRegressionTest { } } + @Test void preBindingCachePopulationBlocksSharedTransitionAdmission() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + UUID uuid = UUID.randomUUID(); + CountDownLatch loading = new CountDownLatch(1), release = new CountDownLatch(1); + var data = plugin.getUserManager().getUser(uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenAnswer(call -> { + loading.countDown(); + await(release); + return new HashMap(); + }); + var worker = Executors.newSingleThreadExecutor(); + try { + var population = worker.submit(() -> manager.cacheUser(uuid, null)); + await(loading); + assertThrows(IllegalStateException.class, manager::beginSharedBindingTransition); + release.countDown(); + population.get(5, TimeUnit.SECONDS); + assertDoesNotThrow(manager::beginSharedBindingTransition); + manager.endSharedBindingTransition(); + } finally { + release.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + @Test void cachePopulationCannotPublishAfterBackendReplacementStarts() throws Exception { try (Fixture fixture = new Fixture()) { SharedUserDataRuntime runtime = fixture.runtime(); @@ -330,6 +359,23 @@ private void cacheMiss(UserDataFetchMode mode) { } } + @Test void managerDrivenRemovalEvictsTheOwnersPerUserGate() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.getCache(fixture.uuid); + Field gatesField = BukkitUserCacheOwner.class.getDeclaredField("cacheGates"); + gatesField.setAccessible(true); + @SuppressWarnings("unchecked") + Map gates = (Map) gatesField.get(fixture.owner); + assertTrue(gates.containsKey(fixture.uuid)); + + fixture.manager.removeCache(fixture.uuid, null); + + assertFalse(gates.containsKey(fixture.uuid)); + runtime.close(); + } + } + private static void await(CountDownLatch latch) throws InterruptedException { assertTrue(latch.await(5, TimeUnit.SECONDS)); } private static final class Fixture implements AutoCloseable { 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 63ee37a30..38bf514e0 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 @@ -144,19 +144,18 @@ void sameBackendReplacementDoesNotCloseItOrDiscardPendingWrites() { } @Test - void scheduledBatchAndNotificationAreInsideTheShutdownBarrier() throws Exception { + void scheduledBatchCompletesWithoutKeepingNotificationInsideTheShutdownBarrier() throws Exception { Fixture fixture = new Fixture(); SharedUserDataRuntime runtime = fixture.runtime(); CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1); - AtomicBoolean firstWrite = new AtomicBoolean(true), firstNotice = new AtomicBoolean(true); + AtomicBoolean firstWrite = new AtomicBoolean(true); fixture.first.beforeWrite = () -> { if (firstWrite.getAndSet(false)) { entered.countDown(); await(release); } }; var userManager = fixture.plugin.getUserManager(); - doAnswer(call -> { - if (firstNotice.getAndSet(false)) runtime.queueChange(fixture.uuid, "Points", new DataValueInt(8)); - return null; - }).when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + CountDownLatch notified = new CountDownLatch(1); + doAnswer(call -> { notified.countDown(); return null; }) + .when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); ExecutorService workers = Executors.newFixedThreadPool(2); try { @@ -167,7 +166,8 @@ void scheduledBatchAndNotificationAreInsideTheShutdownBarrier() throws Exception release.countDown(); scheduled.get(5, TimeUnit.SECONDS); closed.get(5, TimeUnit.SECONDS); - assertEquals(8, fixture.first.points(fixture.uuid)); + await(notified); + assertEquals(3, fixture.first.points(fixture.uuid)); assertTrue(runtime.isClosed()); } finally { release.countDown(); @@ -176,6 +176,21 @@ void scheduledBatchAndNotificationAreInsideTheShutdownBarrier() throws Exception } } + @Test + void changeCallbackCanTakeExclusiveUserAdmissionForTheSameUuid() { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(3)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + var userManager = fixture.plugin.getUserManager(); + doAnswer(call -> { runtime.remove(fixture.uuid); return null; }) + .when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + + assertDoesNotThrow(cache::processChanges); + assertFalse(fixture.caches.containsKey(fixture.uuid)); + runtime.close(); + } + @Test void callbackCanClearTheCacheAfterItsSharedBatchCompletes() { Fixture fixture = new Fixture(); From dd7e44284aa52c06c786b7eda4e8ceb852d8a261 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:29:53 -0600 Subject: [PATCH 23/34] Harden legacy cache refresh and batching --- .../api/user/usercache/UserDataCache.java | 8 ++- .../user/storage/BukkitSqlUserStorage.java | 2 +- .../storage/BukkitSqlUserStorageTest.java | 6 +- .../user/UserDataCachePopulationRaceTest.java | 57 +++++++++++++++++++ 4 files changed, 68 insertions(+), 5 deletions(-) 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 a5861fe0e..0e3e70a48 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 @@ -91,7 +91,13 @@ public UserDataCache cache() { else manager.getPlugin().devDebug("Loading default cache value for " + key + " for " + currentUuid); refreshed.put(key, dataValue); } - HashMap published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid); + HashMap published; + synchronized (this) { + // A concurrent cache eviction is an expected legacy lifecycle outcome. + // It must not turn a completed storage read into a failed cache request. + if (uuid == null || cache == null) return this; + published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid); + } ArrayList changedKeys = new ArrayList<>(); for (Entry entry : published.entrySet()) { DataValue prior = before.get(entry.getKey()); 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 c69891764..0798b82c4 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 @@ -100,8 +100,8 @@ public void writeValues(UserStorage storage, HashMap values) ArrayList columns = new ArrayList<>(); for (Entry entry : values.entrySet()) { if (!entry.getKey().equals("uuid")) columns.add(new Column(entry.getKey(), entry.getValue())); - owner().getSQLiteUserTable().update(primary(), columns); } + if (!columns.isEmpty()) owner().getSQLiteUserTable().update(primary(), columns); } return null; }); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java index c6c9e055d..45bfb950d 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/BukkitSqlUserStorageTest.java @@ -51,7 +51,7 @@ void constructionDoesNotResolveOwnersAndReadUsesCurrentTableAndUuid() { } @Test - void sqliteBulkPreservesPerEntryCallsCumulativeListAndUuidExclusion() { + void sqliteBulkUsesOneUpdateWithAllNonUuidColumns() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); UserTable table = mock(UserTable.class); when(plugin.getSQLiteUserTable()).thenReturn(table); @@ -70,9 +70,9 @@ void sqliteBulkPreservesPerEntryCallsCumulativeListAndUuidExclusion() { values.put("uuid", new DataValueString("ignored-id")); values.put("PlayerName", new DataValueString("Ben")); storage.writeValues(UserStorage.SQLITE, values); - assertEquals(List.of(List.of("Points"), List.of("Points"), List.of("Points", "PlayerName")), writes); + assertEquals(List.of(List.of("Points", "PlayerName")), writes); storage.writeValues(UserStorage.SQLITE, new HashMap<>()); - assertEquals(3, writes.size()); + assertEquals(1, writes.size()); } @Test diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java index f9954f599..42e0e2ee4 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCachePopulationRaceTest.java @@ -1,22 +1,79 @@ package com.bencodez.advancedcore.tests.user; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import java.util.ArrayList; import java.util.HashMap; import java.util.UUID; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; import com.bencodez.simpleapi.sql.data.DataValueInt; class UserDataCachePopulationRaceTest { + @Test + void retiredCacheMakesAnInFlightLegacyLoadANoop() throws Exception { + UserDataManager manager = mock(UserDataManager.class); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserData data = mock(UserData.class); + ScheduledThreadPoolExecutor timer = new ScheduledThreadPoolExecutor(1); + UUID uuid = UUID.randomUUID(); + CountDownLatch storageRead = new CountDownLatch(1); + CountDownLatch releaseRead = new CountDownLatch(1); + when(manager.getPlugin()).thenReturn(plugin); + when(manager.getTimer()).thenReturn(timer); + when(manager.getKeys()).thenReturn(new ArrayList<>()); + when(plugin.getUserManager()).thenReturn(users); + when(users.getUser(uuid, false)).thenReturn(user); + when(user.getUserData()).thenReturn(data); + when(data.getKeys()).thenReturn(new ArrayList<>()); + when(data.getValues()).thenAnswer(ignored -> { + storageRead.countDown(); + if (!releaseRead.await(5, TimeUnit.SECONDS)) throw new AssertionError("timed out waiting to retire cache"); + return new HashMap<>(); + }); + UserDataCache cache = new UserDataCache(manager, uuid); + AtomicReference failure = new AtomicReference<>(); + Thread loader = new Thread(() -> { + try { + cache.cache(); + } catch (Throwable thrown) { + failure.set(thrown); + } + }); + loader.start(); + try { + org.junit.jupiter.api.Assertions.assertTrue(storageRead.await(5, TimeUnit.SECONDS)); + cache.dump(); + releaseRead.countDown(); + loader.join(5000); + assertDoesNotThrow(() -> { + if (loader.isAlive()) throw new AssertionError("cache load did not finish"); + }); + org.junit.jupiter.api.Assertions.assertNull(failure.get()); + } finally { + releaseRead.countDown(); + loader.join(5000); + timer.shutdownNow(); + } + } + @Test void storageRefreshCannotOverwriteANewerQueuedCacheValue() { UserDataManager manager = mock(UserDataManager.class); From f16db510b1a103e9a0163767bc62f6eefd44497d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:00:23 -0600 Subject: [PATCH 24/34] Integrate shared user cache runtime --- .../advancedcore/AdvancedCorePlugin.java | 108 ++++++-- .../api/user/usercache/UserDataCache.java | 93 ++++++- .../api/user/usercache/UserDataManager.java | 235 ++++++++++++++++-- .../bukkit/runtime/BukkitRuntimePlatform.java | 18 +- .../user/runtime/BukkitUserCacheOwner.java | 35 ++- .../runtime/BukkitUserRuntimeBootstrap.java | 31 +++ .../user/storage/BukkitSqlUserBackend.java | 151 +++++++++++ .../user/storage/BukkitSqlUserStorage.java | 31 ++- .../advancedcore/command/CommandLoader.java | 54 ++-- .../user/runtime/SharedUserDataRuntime.java | 43 +++- .../user/BukkitUserRuntimeBootstrapTest.java | 61 +++++ .../user/BukkitUserRuntimeShutdownTest.java | 43 ++++ .../SharedCacheBindingRegressionTest.java | 144 +++++++++++ .../user/SharedLegacyBackendRoutingTest.java | 75 +++++- .../SharedUserStorageReloadSafetyTest.java | 99 ++++++++ 15 files changed, 1118 insertions(+), 103 deletions(-) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 3b82d9b01..70f649c9f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -11,7 +11,9 @@ import java.util.LinkedList; import java.util.Map.Entry; import java.util.Queue; -import java.util.UUID; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -43,10 +45,12 @@ import com.bencodez.advancedcore.api.time.TimeChecker; import com.bencodez.advancedcore.api.time.TimeType; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; -import com.bencodez.advancedcore.api.user.UserDataFetchMode; -import com.bencodez.advancedcore.api.user.UserManager; -import com.bencodez.advancedcore.api.user.UserStartup; -import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStartup; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.advancedcore.command.CommandLoader; @@ -508,10 +512,35 @@ public void run() { * @param from the source storage type * @param to the target storage type */ - public void convertDataStorage(UserStorage from, UserStorage to) { - debug("Starting convert process"); - if (to == null) { - throw new RuntimeException("Invalid Storage Method"); + public void convertDataStorage(UserStorage from, UserStorage to) { + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("User storage conversion must run asynchronously; use convertDataStorageAsync"); + } + getUserManager().getDataManager().runStorageMaintenance(() -> convertDataStorageNow(from, to)); + } + + /** + * Start an explicit SQL-to-SQL conversion without blocking the server thread. + * The result completes after the shared cache generation was flushed and the + * converter has finished; callers must not report success before then. + */ + public CompletionStage convertDataStorageAsync(UserStorage from, UserStorage to) { + CompletableFuture result = new CompletableFuture<>(); + try { + getBukkitScheduler().runTaskAsynchronously(this, () -> { + try { + convertDataStorage(from, to); + result.complete(null); + } catch (Throwable failure) { result.completeExceptionally(failure); } + }); + } catch (RuntimeException | Error failure) { result.completeExceptionally(failure); } + return result; + } + + private void convertDataStorageNow(UserStorage from, UserStorage to) { + debug("Starting convert process"); + if (to == null) { + throw new RuntimeException("Invalid Storage Method"); } loadUserAPI(from); loadUserAPI(to); @@ -630,12 +659,15 @@ public UserStorage getStorageType() { * * @return the user manager */ - public UserManager getUserManager() { + public UserManager getUserManager() { if (userManager == null) { userManager = new UserManager(this); } - return userManager; - } + return userManager; + } + + /** Existing manager only; shutdown must not allocate a new user subsystem. */ + public UserManager getLoadedUserManager() { return userManager; } private YamlConfiguration getVersionFile() { try { @@ -690,12 +722,19 @@ public void loadAdvancedCoreEvents() { Bukkit.getPluginManager().registerEvents(new BInventoryListener(this), this); } - private void loadConfig(boolean userStorage) { - getOptions().load(this); - if (loadUserData && userStorage) { - loadUserAPI(getOptions().getStorageType()); - } - } + private void loadConfig(boolean userStorage) { + getOptions().load(this); + if (loadUserData && userStorage) { + loadUserAPI(getOptions().getStorageType()); + bindSharedUserRuntime(); + } + } + + /** Bind only after the native Bukkit storage owner has initialized successfully. */ + private void bindSharedUserRuntime() { + UserDataManager manager = getUserManager().getDataManager(); + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(this, manager); + } private void loadHandle() { @@ -1023,11 +1062,12 @@ public void updateReplacements() { * * @param storageType the storage type to load */ - public void loadUserAPI(UserStorage storageType) { - if (storageType == null) { - throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); - } - if (storageType.equals(UserStorage.SQLITE)) { + public void loadUserAPI(UserStorage storageType) { + if (storageType == null) { + throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); + } + requireUserStorageMaintenanceWindow(); + if (storageType.equals(UserStorage.SQLITE)) { ArrayList columns = new ArrayList<>(); Column key = new Column("uuid", DataType.STRING); columns.add(key); @@ -1046,7 +1086,22 @@ public void loadUserAPI(UserStorage storageType) { } } - } + } + + /** + * The shared runtime owns cache flushing and the lifecycle admission for the + * native SQL provider. Replacing that provider in-place would let an in-flight + * flush target a connection that reload has already replaced or closed. There + * is no safe synchronous Bukkit hot-reload boundary for this today, so require + * a full plugin restart before mutating either native storage owner. + */ + private void requireUserStorageMaintenanceWindow() { + UserManager loadedUsers = getLoadedUserManager(); + if (loadedUsers != null && loadedUsers.getDataManager().hasSharedRuntimeLifecycle() + && !loadedUsers.getDataManager().isStorageMaintenanceActive()) { + throw new IllegalStateException("User storage reload requires a full plugin restart while shared user storage is active or retiring"); + } + } private void loadUUIDs() { @@ -1206,8 +1261,9 @@ public void reloadAdvancedCore() { * * @param userStorage whether to reload user storage */ - public void reloadAdvancedCore(boolean userStorage) { - getServerDataFile().reloadData(); + public void reloadAdvancedCore(boolean userStorage) { + if (userStorage) requireUserStorageMaintenanceWindow(); + getServerDataFile().reloadData(); rewardHandler.loadRewards(); loadConfig(userStorage); 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 0e3e70a48..79d1ae139 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 @@ -25,8 +25,10 @@ public class UserDataCache { private long snapshotVersion; private long replacementVersion; private final HashMap changedAt = new HashMap<>(); + private final HashMap persistedAt = new HashMap<>(); private final HashMap inFlightValues = new HashMap<>(); private boolean scheduled = false; + private boolean removing; private int inFlightBatches = 0; private volatile Consumer> sharedStorageWriter; private Thread sharedBatchThread; @@ -59,6 +61,11 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue if (change != null && sharedStorageWriter != null && (cache == null || cachedChanges == null)) { throw new IllegalStateException("Shared user cache is retired"); } + // A manager-wide retirement must never make a caller believe that its + // queued mutation was accepted when it was not. The manager normally + // admits writers before this point; this guard also covers a re-entrant + // listener running as the cache is being retired. + if (removing) throw new IllegalStateException("Shared user cache is retiring"); if (change == null || cache == null || cachedChanges == null) return; cache.put(change.getKey(), change.toUserDataValue()); changedAt.put(change.getKey(), ++snapshotVersion); @@ -69,12 +76,22 @@ private synchronized void addChangeInternal(UserDataChange change, boolean queue } public UserDataCache cache() { + cacheInternal(true); + return this; + } + + UserDataCache cacheInternal(boolean notify) { + refreshInternal(notify); + return this; + } + + ArrayList refreshInternal(boolean notify) { initializeSharedStorage(); UUID currentUuid; long expectedVersion; HashMap before; synchronized (this) { - if (uuid == null || cache == null) return this; + if (uuid == null || cache == null) return new ArrayList<>(); currentUuid = uuid; expectedVersion = snapshotVersion; before = new HashMap<>(cache); @@ -95,7 +112,7 @@ public UserDataCache cache() { 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 this; + if (uuid == null || cache == null) return new ArrayList<>(); published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid); } ArrayList changedKeys = new ArrayList<>(); @@ -103,9 +120,9 @@ public UserDataCache cache() { DataValue prior = before.get(entry.getKey()); if (prior != null && entry.getValue() != null && !prior.toString().equals(entry.getValue().toString())) changedKeys.add(entry.getKey()); } - if (!changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); + if (notify && !changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); if (!keys.isEmpty()) manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); - return this; + return changedKeys; } public void clearCache() { @@ -124,16 +141,46 @@ private void clearCacheNow() { return; } } - gate.accept(() -> { processChanges(); synchronized (this) { if (cache != null) { cache.clear(); recordSnapshotReplacement(); } } }); + java.util.concurrent.atomic.AtomicReference notification = new java.util.concurrent.atomic.AtomicReference<>(); + gate.accept(() -> { + notification.set(processChangesInternal(true)); + synchronized (this) { if (cache != null) { cache.clear(); recordSnapshotReplacement(); } } + }); + Runnable callback = notification.get(); + if (callback != null) callback.run(); } public void clearChanges() { - if (!hasChangesToProcess()) return; - if (manager != null && manager.deferSharedStorageWork(this::clearChangesNow)) return; - clearChangesNow(); + Runnable callback = clearChangesForRefresh(); + if (callback != null) callback.run(); } - private void clearChangesNow() { if (hasChangesToProcess()) processChanges(); } + /** + * Flush queued changes for a cache refresh, but leave the user-data callback to + * the caller. Cache refreshes run under shared per-user admission, while a + * callback is permitted to remove that user and therefore needs exclusive + * admission. Running it here would attempt an unsupported lock upgrade. + */ + Runnable clearChangesForRefresh() { + if (!hasChangesToProcess()) return null; + if (manager != null && manager.deferSharedStorageWork(this::clearChangesAndNotify)) return null; + return clearChangesNow(); + } + + private void clearChangesAndNotify() { + Runnable callback = clearChangesNow(); + if (callback != null) callback.run(); + } + + private Runnable clearChangesNow() { + if (!hasChangesToProcess()) return null; + Consumer gate; + synchronized (this) { gate = sharedFlushGate; } + if (gate == null) return processChangesInternal(false); + java.util.concurrent.atomic.AtomicReference notification = new java.util.concurrent.atomic.AtomicReference<>(); + gate.accept(() -> notification.set(processChangesInternal(true))); + return notification.get(); + } public void displayCache() { manager.getPlugin().devDebug(displayCacheStringList().toString()); } public synchronized ArrayList displayCacheStringList() { @@ -192,6 +239,14 @@ public synchronized void retireAfterSharedFlush() { recordSnapshotReplacement(); cache = null; cachedChanges = null; uuid = null; scheduled = false; } + /** Prevent reentrant change listeners from resurrecting a cache being removed. */ + public synchronized void beginRemoval() { removing = true; } + + /** Reopen a cache when a manager-wide removal could not flush this cache. */ + public synchronized void cancelRemoval() { + if (cache != null && cachedChanges != null) removing = false; + } + public void processChanges() { initializeSharedStorage(); Runnable notification = processChangesInternal(false); @@ -203,6 +258,7 @@ private Runnable processChangesInternal(boolean admitted) { Consumer> writer = null; Consumer gate; ArrayList changes = new ArrayList<>(); + HashMap persistedMutationVersions = new HashMap<>(); boolean legacyAdmission = false; synchronized (this) { gate = admitted ? null : sharedFlushGate; @@ -223,6 +279,10 @@ 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); + } inFlightValues.clear(); try { for (UserDataChange changeEntry : changes) inFlightValues.put(changeEntry.getKey(), changeEntry.toUserDataValue()); @@ -252,6 +312,19 @@ private Runnable processChangesInternal(boolean admitted) { for (UserDataChange change : changes) { values.put(change.getKey(), change.toUserDataValue()); keys.add(change.getKey()); } if (!values.isEmpty()) { if (writer == null) user.getUserData().setValues(values); else writer.accept(values); } persisted = true; + synchronized (this) { + if (cache != null) { + for (UserDataChange persistedChange : changes) { + cache.put(persistedChange.getKey(), persistedChange.toUserDataValue()); + } + } + // The in-memory values now represent the successful write. Mark that + // replacement so an older in-flight read cannot overwrite them. + persistedMutationVersions.forEach((key, version) -> persistedAt.put(key, version)); + persistedMutationVersions.forEach((key, version) -> { + if (version.equals(changedAt.get(key))) changedAt.remove(key); + }); + } changedUser = user; changedKeys = ArrayUtils.convert(keys); } catch (RuntimeException | Error e) { @@ -331,8 +404,10 @@ private synchronized HashMap updateSharedSnapshot(HashMap expectedVersion) return new HashMap<>(cache); HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); + persistedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; recordSnapshotReplacement(); + persistedAt.clear(); return new HashMap<>(cache); } 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 3ff67b9ab..22e952293 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 @@ -2,7 +2,7 @@ import java.util.ArrayList; import java.util.Objects; -import java.util.Set; +import java.util.Map.Entry; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -10,6 +10,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.BiConsumer; import java.util.function.BiFunction; import java.util.function.Consumer; @@ -19,6 +20,7 @@ import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.player.UuidLookup; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKey; @@ -27,7 +29,9 @@ import com.bencodez.advancedcore.api.user.usercache.keys.UserDataKeyString; import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; import com.bencodez.simpleapi.debug.DebugLevel; +import com.bencodez.simpleapi.array.ArrayUtils; import lombok.Getter; @@ -42,10 +46,24 @@ public class UserDataManager { private volatile Consumer sharedCacheInitializer; private volatile Consumer sharedCacheRemovalListener; private volatile SharedSqlRoute sharedSqlRoute; + private volatile SharedUserDataRuntime sharedRuntime; + /** True from retirement admission until its native-owner callback has finished. */ + private volatile boolean sharedRuntimeRetiring; private final AtomicReference lastDeferredStorageFailure = new AtomicReference<>(); private final Object sharedBindingAdmission = new Object(); + /** Explicit converter-only bypass for the legacy storage adapter. */ + private final ThreadLocal storageMaintenanceDepth = ThreadLocal.withInitial(() -> 0); private boolean sharedBindingTransition; private int legacyBatches; + private final ReentrantReadWriteLock cacheMapLifecycle = new ReentrantReadWriteLock(true); + + /** Admit shared cache-map population while excluding whole-map clear/replace. */ + public final T withCacheMapReadAdmission(Supplier operation) { + Objects.requireNonNull(operation, "operation"); + cacheMapLifecycle.readLock().lock(); + try { return operation.get(); } + finally { cacheMapLifecycle.readLock().unlock(); } + } private record SharedSqlRoute(SqlUserBackend backend, BiConsumer gate, BiConsumer exclusiveGate) { @@ -147,6 +165,92 @@ public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { public final boolean hasSharedSqlBackend() { return sharedSqlRoute != null; } + /** + * Run an explicit storage-maintenance operation behind the shared runtime's + * write barrier. Calls made by the operation may target a non-current store + * only while this scoped flag is active; ordinary adapters remain protected + * from cross-store writes. + */ + public final void runStorageMaintenance(Runnable operation) { + Objects.requireNonNull(operation, "operation"); + SharedUserDataRuntime runtime = sharedRuntime; + if (runtime == null && sharedRuntimeRetiring) { + throw new IllegalStateException("Shared user runtime retirement is still in progress"); + } + Runnable guarded = () -> { + int previous = storageMaintenanceDepth.get(); + storageMaintenanceDepth.set(previous + 1); + try { operation.run(); } + finally { + if (previous == 0) storageMaintenanceDepth.remove(); + else storageMaintenanceDepth.set(previous); + } + }; + if (runtime == null) guarded.run(); + else runtime.runStorageMaintenance(guarded); + } + + /** True only inside a runtime-exclusive explicit storage maintenance action. */ + public final boolean isStorageMaintenanceActive() { return storageMaintenanceDepth.get() > 0; } + + /** Register the one runtime that owns the shared route for this manager. */ + public final synchronized void bindSharedRuntime(SharedUserDataRuntime runtime) { + Objects.requireNonNull(runtime, "runtime"); + if (sharedRuntimeRetiring) { + throw new IllegalStateException("Shared user runtime retirement is still in progress"); + } + if (sharedRuntime != null && !sharedRuntime.isClosed()) { + throw new IllegalStateException("Shared user runtime is already bound"); + } + sharedRuntime = runtime; + } + + public final boolean hasSharedRuntime() { + SharedUserDataRuntime runtime = sharedRuntime; + return runtime != null && !runtime.isClosed(); + } + + /** + * A native storage replacement must not race the shared runtime's final flush + * and owner cleanup. This remains true while asynchronous retirement is in + * progress even though the runtime is no longer available for new work. + */ + public final synchronized boolean hasSharedRuntimeLifecycle() { + return sharedRuntimeRetiring || hasSharedRuntime(); + } + + /** + * Start shared cache retirement on the manager worker. The callback runs only + * after a successful flush/retirement, so the native owner never closes a + * database while a failed shared write remains queued for recovery. + */ + public final boolean closeSharedRuntimeAsync(Runnable afterRetirement) { + Objects.requireNonNull(afterRetirement, "afterRetirement"); + SharedUserDataRuntime runtime; + synchronized (this) { + runtime = sharedRuntime; + // A second shutdown caller must not interpret an in-flight retirement as + // "no runtime" and close the native provider underneath its final flush. + if (runtime == null) return sharedRuntimeRetiring; + sharedRuntime = null; + sharedRuntimeRetiring = true; + } + runtime.closeAsync(timer).whenComplete((ignored, failure) -> { + if (failure != null) { + synchronized (this) { + if (sharedRuntime == null) sharedRuntime = runtime; + sharedRuntimeRetiring = false; + } + reportDeferredStorageFailure(failure); + return; + } + try { afterRetirement.run(); } + catch (RuntimeException | Error cleanupFailure) { reportDeferredStorageFailure(cleanupFailure); } + finally { synchronized (this) { sharedRuntimeRetiring = false; } } + }); + return true; + } + /** * Route one complete legacy SQL operation through lifecycle admission. The gate * is captured only to enter the current runtime; the backend is resolved after @@ -272,10 +376,26 @@ public void addKey(UserDataKey userDataKey) { @Deprecated public void cacheUser(UUID uuid) { - withSharedCacheAdmission(uuid, () -> cacheUserNow(uuid, true)); + CacheRefresh refreshed = new CacheRefresh(uuid); + RuntimeException runtimeFailure = null; + Error errorFailure = null; + cacheMapLifecycle.readLock().lock(); + try { withSharedCacheAdmission(uuid, () -> cacheUserNow(uuid, true, refreshed)); } + catch (RuntimeException failure) { runtimeFailure = failure; } + catch (Error failure) { errorFailure = failure; } + finally { cacheMapLifecycle.readLock().unlock(); } + if (runtimeFailure != null) { + notifyCacheChangesAfterFailure(refreshed, runtimeFailure); + throw runtimeFailure; + } + if (errorFailure != null) { + notifyCacheChangesAfterFailure(refreshed, errorFailure); + throw errorFailure; + } + notifyCacheChanges(refreshed); } - private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall) { + private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall, CacheRefresh refresh) { plugin.devDebug("Caching " + uuid.toString()); if (traceDevelopmentCall && plugin.getOptions().getDebug().isDebug(DebugLevel.DEV)) { try { throw new Exception("caching here: " + uuid.toString()); } @@ -283,10 +403,11 @@ private void cacheUserNow(UUID uuid, boolean traceDevelopmentCall) { } if (userDataCache.containsKey(uuid)) { UserDataCache data = userDataCache.get(uuid); - data.clearChanges(); - data.cache(); + refresh.flushNotification = data.clearChangesForRefresh(); + refresh.changed = data.refreshInternal(false); } else { - UserDataCache data = new UserDataCache(this, uuid).cache(); + UserDataCache data = new UserDataCache(this, uuid); + refresh.changed = data.refreshInternal(false); if (data.hasCache()) userDataCache.put(uuid, data); } } @@ -296,7 +417,48 @@ public void cacheUser(UUID uuid, String playerName) { uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } UUID target = uuid; - withSharedCacheAdmission(target, () -> cacheUserNow(target, false)); + CacheRefresh refreshed = new CacheRefresh(target); + RuntimeException runtimeFailure = null; + Error errorFailure = null; + cacheMapLifecycle.readLock().lock(); + try { withSharedCacheAdmission(target, () -> cacheUserNow(target, false, refreshed)); } + catch (RuntimeException failure) { runtimeFailure = failure; } + catch (Error failure) { errorFailure = failure; } + finally { cacheMapLifecycle.readLock().unlock(); } + if (runtimeFailure != null) { + notifyCacheChangesAfterFailure(refreshed, runtimeFailure); + throw runtimeFailure; + } + if (errorFailure != null) { + notifyCacheChangesAfterFailure(refreshed, errorFailure); + throw errorFailure; + } + notifyCacheChanges(refreshed); + } + + private void notifyCacheChangesAfterFailure(CacheRefresh refresh, Throwable originalFailure) { + try { notifyCacheChanges(refresh); } + catch (RuntimeException | Error notificationFailure) { originalFailure.addSuppressed(notificationFailure); } + } + + private void notifyCacheChanges(CacheRefresh refresh) { + if (refresh == null) return; + if (refresh.flushNotification != null) refresh.flushNotification.run(); + ArrayList changed = refresh.changed; + if (!changed.isEmpty()) { + // The notification intentionally runs after lifecycle admission so listeners + // can remove or replace this user's cache. Capture the identity with the + // refresh result; the old cache may already have been retired here. + AdvancedCoreUser user = plugin.getUserManager().getUser(refresh.uuid, false); + plugin.getUserManager().onChange(user, ArrayUtils.convert(changed)); + } + } + + private static final class CacheRefresh { + private final UUID uuid; + private ArrayList changed = new ArrayList<>(); + private Runnable flushNotification; + private CacheRefresh(UUID uuid) { this.uuid = uuid; } } public void cacheUserIfNeeded(UUID uuid) { if (!userDataCache.containsKey(uuid)) cacheUser(uuid); } @@ -342,12 +504,46 @@ public void clearCache() { } private void clearCacheNow() { - plugin.debug("Clearing cache: " + userDataCache.keySet().size()); - Set removed = new java.util.HashSet<>(userDataCache.keySet()); - for (UserDataCache c : userDataCache.values()) { c.clearCache(); c.dump(); } - userDataCache.clear(); + // Do not retain this map lock while flushing through a shared runtime gate. + // A population already admitted by that runtime needs the map read lock to + // publish, while a queued lifecycle writer would otherwise block this flush. + // Mark the current generation as retiring under the map lock, then perform + // its storage work without that lock and finally detach only those instances. + java.util.HashMap retiring = new java.util.HashMap<>(); + cacheMapLifecycle.writeLock().lock(); + try { + plugin.debug("Clearing cache: " + userDataCache.keySet().size()); + retiring.putAll(userDataCache); + for (UserDataCache cache : retiring.values()) cache.beginRemoval(); + } finally { cacheMapLifecycle.writeLock().unlock(); } + try { + for (Entry entry : retiring.entrySet()) { + UUID uuid = entry.getKey(); + UserDataCache cache = entry.getValue(); + // The map write lock only protects publication. Retiring each cache + // must also exclude the normal per-user read admission used by + // addChange(), otherwise a queued setter can observe this mapped cache + // after beginRemoval() and have its mutation silently discarded. + withSharedSqlBackendExclusive(uuid, () -> clearCacheExclusively(uuid, cache)); + } + } catch (RuntimeException | Error failure) { + // All not-yet-detached caches were marked before flushing began. Reopen + // each surviving cache so one user's failed write cannot discard later + // updates for another cache that has not been flushed yet. + for (UserDataCache cache : retiring.values()) cache.cancelRemoval(); + throw failure; + } + } + + private void clearCacheExclusively(UUID uuid, UserDataCache cache) { + cache.clearCache(); + cache.dump(); + boolean removed; + cacheMapLifecycle.writeLock().lock(); + try { removed = userDataCache.remove(uuid, cache); } + finally { cacheMapLifecycle.writeLock().unlock(); } Consumer listener = sharedCacheRemovalListener; - if (listener != null) removed.forEach(listener); + if (removed && listener != null) listener.accept(uuid); } public void clearCacheBasic() { @@ -394,15 +590,22 @@ public void removeCache(UUID uuid, String playerName) { } private void removeCacheNow(UUID uuid) { - boolean shared = sharedSqlRoute != null; - withSharedSqlBackendExclusive(uuid, () -> removeCacheExclusively(uuid, shared)); + withSharedSqlBackendExclusive(uuid, () -> removeCacheExclusively(uuid, sharedSqlRoute != null)); } private void removeCacheExclusively(UUID uuid, boolean shared) { UserDataCache cache = getCache(uuid); if (cache != null) { - cache.clearCache(); - if (shared) cache.retireAfterSharedFlush(); + cache.beginRemoval(); + try { + cache.clearCache(); + if (shared) cache.retireAfterSharedFlush(); + } catch (RuntimeException | Error failure) { + // The failed cache is still mapped with its queued write retained. It + // must resume accepting mutations so a later cleanup can retry safely. + cache.cancelRemoval(); + throw failure; + } } boolean removed = cache == null ? userDataCache.remove(uuid) != null : userDataCache.remove(uuid, cache); Consumer listener = sharedCacheRemovalListener; 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 5d54765d9..f0caaa279 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 @@ -8,6 +8,7 @@ import com.bencodez.advancedcore.api.item.FullInventoryHandler; import com.bencodez.advancedcore.api.javascript.JavascriptEngineHandler; import com.bencodez.advancedcore.api.time.TimeChecker; +import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.core.platform.RuntimePlatform; @@ -35,12 +36,7 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { JavascriptEngineHandler.getInstance().clearCachedEngine(); } }), - new Cleanup("MySQL", () -> { - if (plugin.isLoadUserData() && plugin.getOptions() != null - && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) { - plugin.getMysql().close(); - } - }), + new Cleanup("user storage", this::closeUserStorageAfterSharedRetirement), new Cleanup("server data timestamp", () -> { if (plugin.getServerDataFile() != null) plugin.getServerDataFile().setLastUpdated(); })); @@ -81,4 +77,14 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { plugin.getLogger().warning("Failed to shut down " + component + ": " + failure.getMessage()); plugin.debug(failure); } + + private void closeUserStorageAfterSharedRetirement() { + if (!plugin.isLoadUserData()) return; + Runnable closeMysql = () -> { + if (plugin.getOptions() != null && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) + && plugin.getMysql() != null) plugin.getMysql().close(); + }; + UserManager users = plugin.getLoadedUserManager(); + if (users == null || !users.getDataManager().closeSharedRuntimeAsync(closeMysql)) closeMysql.run(); + } } 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 cceea5778..02d7e1e8a 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 @@ -149,6 +149,17 @@ private void bind(UserDataCache cache, UUID uuid) { } @Override public void populate(UUID uuid, HashMap values) { + Boolean admitted = manager.withCacheMapReadAdmission(() -> { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + cache.updateCachePreservingPending(values); + return Boolean.TRUE; + }); + // Test doubles and legacy adapters may not implement the optional admission hook. + if (admitted == null) populateWithoutMapAdmission(uuid, values); + } + + private void populateWithoutMapAdmission(UUID uuid, HashMap values) { UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); cache.updateCachePreservingPending(values); @@ -157,6 +168,12 @@ private void bind(UserDataCache cache, UUID uuid) { private record CachePopulation(UUID uuid, UserDataCache cache, long version) implements PopulationToken {} @Override public PopulationToken beginPopulation(UUID uuid) { + CachePopulation admitted = manager.withCacheMapReadAdmission(() -> { + UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); + bind(cache, uuid); + return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); + }); + if (admitted != null) return admitted; UserDataCache cache = manager.getUserDataCache().computeIfAbsent(uuid, ignored -> new UserDataCache(manager, uuid)); bind(cache, uuid); return new CachePopulation(uuid, cache, cache.getSharedSnapshotVersion()); @@ -165,11 +182,21 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp @Override public HashMap completePopulation(UUID uuid, HashMap values, PopulationToken token) { if (!(token instanceof CachePopulation expected) || !uuid.equals(expected.uuid())) throw new IllegalArgumentException("Population token does not belong to this user"); AtomicReference> populated = new AtomicReference<>(); - manager.getUserDataCache().compute(uuid, (ignored, current) -> { - if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); - populated.set(current.updateSharedSnapshot(values, expected.version())); - return current; + HashMap admitted = manager.withCacheMapReadAdmission(() -> { + manager.getUserDataCache().compute(uuid, (ignored, current) -> { + if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); + populated.set(current.updateSharedSnapshot(values, expected.version())); + return current; + }); + return populated.get(); }); + if (admitted == null) { + manager.getUserDataCache().compute(uuid, (ignored, current) -> { + if (current != expected.cache()) throw new IllegalStateException("User cache changed while loading its database snapshot"); + populated.set(current.updateSharedSnapshot(values, expected.version())); + return current; + }); + } return populated.get(); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java new file mode 100644 index 000000000..9cb13a836 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserRuntimeBootstrap.java @@ -0,0 +1,31 @@ +package com.bencodez.advancedcore.bukkit.user.runtime; + +import java.util.Objects; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; + +/** Creates the shared route only after Bukkit has initialized its native user storage. */ +public final class BukkitUserRuntimeBootstrap { + private BukkitUserRuntimeBootstrap() {} + + public static void bindAfterStorageInitialization(AdvancedCorePlugin plugin, UserDataManager manager) { + Objects.requireNonNull(plugin, "plugin"); + Objects.requireNonNull(manager, "manager"); + if (manager.hasSharedRuntime()) return; + if (manager.hasSharedRuntimeLifecycle()) { + throw new IllegalStateException("Shared user storage retirement is still in progress"); + } + SharedUserDataRuntime runtime = new SharedUserDataRuntime(new BukkitSqlUserBackend(plugin), + new BukkitUserCacheOwner(manager)); + try { + manager.bindSharedRuntime(runtime); + } catch (RuntimeException | Error failure) { + try { runtime.closeAsync(manager.getTimer()); } + catch (RuntimeException | Error cleanupFailure) { failure.addSuppressed(cleanupFailure); } + throw failure; + } + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java new file mode 100644 index 000000000..7da77b013 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserBackend.java @@ -0,0 +1,151 @@ +package com.bencodez.advancedcore.bukkit.user.storage; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.Column; +import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueString; + +/** + * Non-owning bridge to the Bukkit storage objects already initialized by the + * plugin. Closing this bridge only retires the shared route; the plugin keeps + * ownership of MySQL/UserTable resource shutdown. + */ +public final class BukkitSqlUserBackend implements SqlUserBackend { + private final AdvancedCorePlugin plugin; + private final Object sqliteOperations = new Object(); + private final AtomicBoolean open = new AtomicBoolean(true); + + public BukkitSqlUserBackend(AdvancedCorePlugin plugin) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + if (storageType() == UserStorage.MYSQL) requireMysql(); + else requireTable(); + } + + @Override public UserStorage storageType() { return plugin.getStorageType(); } + + @Override + public SqlUserStorage user(UUID uuid) { + Objects.requireNonNull(uuid, "uuid"); + requireOpen(); + return new SqlUserStorage() { + @Override public List readRow(UserStorage storage) { return read(storage, uuid); } + @Override public boolean contains(UserStorage storage) { return BukkitSqlUserBackend.this.contains(storage, uuid); } + @Override public void delete(UserStorage storage) { BukkitSqlUserBackend.this.delete(storage, uuid); } + @Override public void write(UserStorage storage, String key, DataValue value) { + BukkitSqlUserBackend.this.write(storage, uuid, key, value); + } + @Override public void writeValues(UserStorage storage, HashMap values) { + BukkitSqlUserBackend.this.writeValues(storage, uuid, values); + } + }; + } + + @Override + public List enumerateUsers() { + ArrayList users = new ArrayList<>(); + forEachUser(uuid -> { + if (users.size() >= MAX_MATERIALIZED_USERS) { + throw new IllegalStateException("User enumeration exceeds " + MAX_MATERIALIZED_USERS + " entries; use forEachUser"); + } + users.add(uuid); + }); + return users; + } + + @Override + public void forEachUser(Consumer consumer) { + Objects.requireNonNull(consumer, "consumer"); + requireOpen(); + UserStorage storage = storageType(); + if (storage == UserStorage.MYSQL) { + mysql().forEachUser((uuid, ignored) -> consumer.accept(uuid), ignored -> {}); + return; + } + synchronized (sqliteOperations) { + table().forEachUser((uuid, ignored) -> consumer.accept(uuid), ignored -> {}); + } + } + + @Override public boolean isOpen() { return open.get(); } + + /** The Bukkit plugin owns and closes the underlying connections. */ + @Override public void close() { open.set(false); } + + private List read(UserStorage storage, UUID uuid) { + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) return mysql().getExact(uuid.toString()); + synchronized (sqliteOperations) { return table().getExact(primary(uuid)); } + } + + private boolean contains(UserStorage storage, UUID uuid) { + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) return mysql().containsKey(uuid.toString()); + synchronized (sqliteOperations) { return table().containsKey(uuid.toString()); } + } + + private void delete(UserStorage storage, UUID uuid) { + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) mysql().deletePlayer(uuid.toString()); + else synchronized (sqliteOperations) { table().delete(primary(uuid)); } + } + + private void write(UserStorage storage, UUID uuid, String key, DataValue value) { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(value, "value"); + requireOpen(); + requireStorage(storage); + if (storage == UserStorage.MYSQL) mysql().update(uuid.toString(), key, value); + else synchronized (sqliteOperations) { table().update(primary(uuid), new ArrayList<>(List.of(new Column(key, value)))); } + } + + private void writeValues(UserStorage storage, UUID uuid, HashMap values) { + Objects.requireNonNull(values, "values"); + requireOpen(); + requireStorage(storage); + ArrayList columns = new ArrayList<>(); + values.forEach((key, value) -> { + if (!"uuid".equalsIgnoreCase(key) && value != null) columns.add(new Column(key, value)); + }); + if (columns.isEmpty()) return; + if (storage == UserStorage.MYSQL) mysql().update(uuid.toString(), columns, false); + else synchronized (sqliteOperations) { table().update(primary(uuid), columns); } + } + + private Column primary(UUID uuid) { return new Column("uuid", new DataValueString(uuid.toString())); } + private MySQL mysql() { return requireMysql(); } + private UserTable table() { return requireTable(); } + + private MySQL requireMysql() { + MySQL mysql = plugin.getMysql(); + if (mysql == null) throw new IllegalStateException("Bukkit MySQL user storage is unavailable"); + return mysql; + } + + private UserTable requireTable() { + UserTable table = plugin.getSQLiteUserTable(); + if (table == null) throw new IllegalStateException("Bukkit SQLite user storage is unavailable"); + return table; + } + + private void requireOpen() { if (!open.get()) throw new IllegalStateException("Bukkit SQL user backend is retired"); } + private void requireStorage(UserStorage storage) { + if (storage != storageType()) throw new IllegalArgumentException( + "Storage mismatch: backend=" + storageType() + ", requested=" + storage); + } +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/storage/BukkitSqlUserStorage.java index 0798b82c4..8cbf11b94 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 @@ -38,31 +38,40 @@ private UserDataManager dataManager() { return manager; } - private T routed(BiFunction sharedOperation, - Supplier legacyOperation) { - UserDataManager manager = dataManager(); - return manager == null ? legacyOperation.get() - : manager.withSharedSqlBackendOrLegacy(this::userId, sharedOperation, legacyOperation); - } + private T routed(UserStorage requestedStorage, BiFunction sharedOperation, + Supplier legacyOperation) { + UserDataManager manager = dataManager(); + // Cross-store access is valid only for the converter's runtime-exclusive + // maintenance action. Outside that narrow scope, route every operation + // through the shared backend so a request cannot silently hit another store. + return manager == null || manager.isStorageMaintenanceActive() ? legacyOperation.get() + : manager.withSharedSqlBackendOrLegacy(this::userId, (sharedStorage, target) -> { + if (sharedStorage != requestedStorage) { + throw new IllegalStateException("Cannot access " + requestedStorage + + " user storage while the shared runtime owns " + sharedStorage); + } + return sharedOperation.apply(requestedStorage, target); + }, legacyOperation); + } @Override public List readRow(UserStorage storage) { Objects.requireNonNull(storage, "storage"); - return routed((type, target) -> target.readRow(type), () -> storage == UserStorage.MYSQL + return routed(storage, (type, target) -> target.readRow(type), () -> storage == UserStorage.MYSQL ? owner().getMysql().getExact(uuid.get()) : owner().getSQLiteUserTable().getExact(primary())); } @Override public boolean contains(UserStorage storage) { Objects.requireNonNull(storage, "storage"); - return routed((type, target) -> target.contains(type), () -> storage == UserStorage.MYSQL + return routed(storage, (type, target) -> target.contains(type), () -> storage == UserStorage.MYSQL ? owner().getMysql().containsKey(uuid.get()) : owner().getSQLiteUserTable().containsKey(uuid.get())); } @Override public void delete(UserStorage storage) { Objects.requireNonNull(storage, "storage"); - routed((type, target) -> { target.delete(type); return null; }, () -> { + routed(storage, (type, target) -> { target.delete(type); return null; }, () -> { if (storage == UserStorage.MYSQL) owner().getMysql().deletePlayer(uuid.get()); else owner().getSQLiteUserTable().delete(primary()); return null; @@ -72,7 +81,7 @@ public void delete(UserStorage storage) { @Override public void write(UserStorage storage, String key, DataValue value) { Objects.requireNonNull(storage, "storage"); - routed((type, target) -> { target.write(type, key, value); return null; }, () -> { + routed(storage, (type, target) -> { target.write(type, key, value); return null; }, () -> { if (storage == UserStorage.SQLITE) { ArrayList columns = new ArrayList<>(); Column primary = primary(); @@ -87,7 +96,7 @@ public void write(UserStorage storage, String key, DataValue value) { @Override public void writeValues(UserStorage storage, HashMap values) { Objects.requireNonNull(storage, "storage"); - routed((type, target) -> { target.writeValues(type, values); return null; }, () -> { + routed(storage, (type, target) -> { target.writeValues(type, values); return null; }, () -> { if (storage == UserStorage.MYSQL) { if (owner().getMysql() != null) { ArrayList columns = new ArrayList<>(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java index 79833f3a6..484c2aae5 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java @@ -629,35 +629,29 @@ public void execute(CommandSender sender, String[] args) { } }); - if (plugin.isLoadUserData()) { + if (plugin.isLoadUserData()) { cmds.add(new CommandHandler(plugin, new String[] { "ConvertToData", "(UserStorage)" }, permPrefix + ".Commands.AdminVote.ConvertToData", "Convert user storage from current storage type to the one specified", true, true) { - @Override - public void execute(CommandSender sender, String[] args) { - sendMessage(sender, - "&cStarting convert from " + plugin.getStorageType().toString() + " to " + args[1]); - plugin.convertDataStorage(plugin.getStorageType(), UserStorage.value(args[1])); - sendMessage(sender, "&cFinished converting"); - } + @Override + public void execute(CommandSender sender, String[] args) { + startStorageConversion(sender, plugin.getStorageType(), UserStorage.value(args[1])); + } }); cmds.add(new CommandHandler(plugin, new String[] { "ConvertFromData", "(UserStorage)" }, permPrefix + ".Commands.AdminVote.ConvertFromData", "Convert user storage from the specified storage type to the current one", true, true) { - @Override - public void execute(CommandSender sender, String[] args) { - sendMessage(sender, - "&cStarting convert from " + args[1] + " to " + plugin.getStorageType().toString()); - plugin.convertDataStorage(UserStorage.value(args[1]), plugin.getStorageType()); - sendMessage(sender, "&cFinished converting"); - } + @Override + public void execute(CommandSender sender, String[] args) { + startStorageConversion(sender, UserStorage.value(args[1]), plugin.getStorageType()); + } }); - } - - cmds.add(new CommandHandler(plugin, new String[] { "SetInputMethod", "(InputMethod)" }, + } + + cmds.add(new CommandHandler(plugin, new String[] { "SetInputMethod", "(InputMethod)" }, permPrefix + ".InputMethod", "Set your value request input method", false) { @Override @@ -689,10 +683,26 @@ public void execute(CommandSender sender, String[] args) { cmd.setAdvancedCoreCommand(true); } - return cmds; - } - - /** + return cmds; + } + + private void startStorageConversion(CommandSender sender, UserStorage from, UserStorage to) { + sender.sendMessage(MessageAPI.colorize("&cStarting convert from " + from + " to " + to)); + plugin.convertDataStorageAsync(from, to).whenComplete((ignored, failure) -> + plugin.getBukkitScheduler().runTask(plugin, () -> { + if (failure == null) { + sender.sendMessage(MessageAPI.colorize("&cFinished converting")); + return; + } + // JDBC/provider exceptions can embed connection details. The storage + // layer records safe diagnostics; do not expose the raw exception here. + plugin.getLogger().severe("User storage conversion failed (" + + failure.getClass().getSimpleName() + ")"); + sender.sendMessage(MessageAPI.colorize("&cUser storage conversion failed; see the server log")); + })); + } + + /** * Gets the basic commands. * * @param permPrefix the permission prefix 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 fc1ac972c..6ce449b4b 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 @@ -30,6 +30,8 @@ public final class SharedUserDataRuntime implements AutoCloseable { private final AtomicBoolean retiring = new AtomicBoolean(); private final Object closeLock = new Object(); private volatile SqlUserBackend backend; + /** A replacement whose close failed remains here for a later safe retry. */ + private volatile SqlUserBackend pendingBackendClose; private volatile boolean closed; private CompletableFuture closeAttempt; @@ -123,6 +125,26 @@ public void flushAll() { }); } + /** + * Run an explicitly requested native-storage maintenance operation while no + * shared cache read, write, replacement, or shutdown operation can overlap + * it. The caller supplies the storage-specific work; this runtime first + * durably flushes and retires its cache generation so a provider change + * cannot split queued updates across the old and replacement owners. + */ + public void runStorageMaintenance(Runnable operation) { + Objects.requireNonNull(operation, "operation"); + rejectReentrantTransition(); + cacheOwner.requireBlockingAllowed(); + lifecycle.writeLock().lock(); + try { + requireOpen(); + flushAllInternal(); + cacheOwner.clearAfterFlush(); + operation.run(); + } finally { lifecycle.writeLock().unlock(); } + } + private void flushAllInternal() { for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) flushInternal(uuid); } public void replaceBackend(SqlUserBackend replacement) { @@ -132,6 +154,7 @@ public void replaceBackend(SqlUserBackend replacement) { lifecycle.writeLock().lock(); try { requireOpen(); + retryPendingBackendClose(); if (replacement == backend) return; if (!replacement.isOpen()) throw new IllegalArgumentException("replacement backend is closed"); flushAllInternal(); @@ -139,10 +162,21 @@ public void replaceBackend(SqlUserBackend replacement) { SqlUserBackend previous = backend; cacheOwner.bindBackend(replacement); backend = replacement; - previous.close(); + try { previous.close(); } + catch (RuntimeException | Error failure) { + pendingBackendClose = previous; + throw failure; + } } finally { lifecycle.writeLock().unlock(); } } + private void retryPendingBackendClose() { + SqlUserBackend pending = pendingBackendClose; + if (pending == null) return; + pending.close(); + pendingBackendClose = null; + } + public void remove(UUID uuid) { Objects.requireNonNull(uuid, "uuid"); cacheOwner.requireBlockingAllowed(); @@ -176,9 +210,10 @@ public CompletionStage closeAsync(Executor executor) { try { if (!closed) { flushAllInternal(); - cacheOwner.clearAfterFlush(); - cacheOwner.shutdown(); - backend.close(); + cacheOwner.clearAfterFlush(); + cacheOwner.shutdown(); + retryPendingBackendClose(); + backend.close(); closed = true; } } finally { lifecycle.writeLock().unlock(); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java new file mode 100644 index 000000000..a9ac88413 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java @@ -0,0 +1,61 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; + +class BukkitUserRuntimeBootstrapTest { + @Test void bindsOnlyAfterExistingMysqlStorageIsAvailableAndDoesNotOwnItsClose() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + when(mysql.getExact(anyString())).thenReturn(new ArrayList<>()); + UserDataManager manager = new UserDataManager(plugin); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + assertTrue(manager.hasSharedRuntime()); + UUID uuid = UUID.randomUUID(); + manager.withSharedSqlBackend(uuid, (storage, user) -> user.readRow(storage)); + verify(mysql).getExact(uuid.toString()); + + CountDownLatch retired = new CountDownLatch(1); + assertTrue(manager.closeSharedRuntimeAsync(retired::countDown)); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + verify(mysql, never()).close(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void refusesToBindBeforeNativeStorageInitialization() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + UserDataManager manager = new UserDataManager(plugin); + try { + assertThrows(IllegalStateException.class, + () -> BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager)); + assertFalse(manager.hasSharedSqlBackend()); + } finally { + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java new file mode 100644 index 000000000..ef231aa90 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java @@ -0,0 +1,43 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; + +class BukkitUserRuntimeShutdownTest { + @Test void closesLegacyMysqlOnlyAfterTheSharedRouteRetires() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + MySQL mysql = mock(MySQL.class); + UserManager users = mock(UserManager.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getLoadedUserManager()).thenReturn(users); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + new BukkitRuntimePlatform(plugin).beforeExecutorShutdown().stream() + .filter(action -> action.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + verify(mysql, timeout(5000)).close(); + } finally { + manager.getTimer().shutdownNow(); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java index b6db3d15d..ae6d81c2c 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 @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Timeout; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -376,6 +377,133 @@ private void cacheMiss(UserDataFetchMode mode) { } } + @Test void managerWideClearDoesNotHoldMapAdmissionWhileSharedFlushWaits() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 18), true); + var workers = Executors.newFixedThreadPool(2); + try { + var clearing = workers.submit(fixture.manager::clearCache); + await(writeStarted); + // A shared cache flush is in progress. A population already admitted by + // the runtime must still be able to take the cache-map read admission. + assertTrue(workers.submit(() -> fixture.manager.withCacheMapReadAdmission(() -> Boolean.TRUE)) + .get(200, TimeUnit.MILLISECONDS)); + releaseWrite.countDown(); + clearing.get(5, TimeUnit.SECONDS); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void managerWideClearExcludesQueuedWritesUntilTheCacheIsDetached() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 18), true); + var workers = Executors.newFixedThreadPool(2); + try { + var clearing = workers.submit(fixture.manager::clearCache); + await(writeStarted); + var concurrentWrite = workers.submit(() -> + cache.addChange(new UserDataChangeInt("Points", 19), true)); + assertThrows(java.util.concurrent.TimeoutException.class, + () -> concurrentWrite.get(200, TimeUnit.MILLISECONDS), + "a write must wait for the exclusive retirement instead of being silently dropped"); + releaseWrite.countDown(); + clearing.get(5, TimeUnit.SECONDS); + assertThrows(java.util.concurrent.ExecutionException.class, + () -> concurrentWrite.get(5, TimeUnit.SECONDS)); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + assertEquals(18, fixture.first.points(fixture.uuid)); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void failedManagerWideClearReopensEveryStillMappedCache() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + try { + UserDataCache first = failingCache(manager, UUID.randomUUID()); + UserDataCache second = failingCache(manager, UUID.randomUUID()); + manager.getUserDataCache().put(first.getUuid(), first); + manager.getUserDataCache().put(second.getUuid(), second); + manager.bindSharedSqlBackend(mock(SqlUserBackend.class), (uuid, operation) -> operation.run()); + + assertThrows(IllegalStateException.class, manager::clearCache); + + first.setSharedStorageWriter(values -> {}); + second.setSharedStorageWriter(values -> {}); + first.addChange(new UserDataChangeInt("Retry", 1), true); + second.addChange(new UserDataChangeInt("Retry", 1), true); + assertTrue(first.getCache().containsKey("Retry")); + assertTrue(second.getCache().containsKey("Retry")); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void failedPerUserRemovalReopensTheMappedCache() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + try { + UserDataCache cache = failingCache(manager, UUID.randomUUID()); + manager.getUserDataCache().put(cache.getUuid(), cache); + manager.bindSharedSqlBackend(mock(SqlUserBackend.class), (uuid, operation) -> operation.run()); + + assertThrows(IllegalStateException.class, () -> manager.removeCache(cache.getUuid(), null)); + + cache.setSharedStorageWriter(values -> {}); + cache.addChange(new UserDataChangeInt("Retry", 1), true); + assertTrue(cache.getCache().containsKey("Retry")); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void refreshFlushCallbackCanRemoveTheSameUserAfterSharedAdmission() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + cache.addChange(new UserDataChangeInt("Points", 21), true); + var userManager = fixture.plugin.getUserManager(); + doAnswer(call -> { + fixture.manager.removeCache(fixture.uuid, null); + return null; + }).when(userManager).onChange(any(AdvancedCoreUser.class), any(String[].class)); + + assertDoesNotThrow(() -> fixture.manager.cacheUser(fixture.uuid, null)); + + assertEquals(21, fixture.first.points(fixture.uuid)); + assertFalse(fixture.manager.containsKey(fixture.uuid)); + runtime.close(); + } + } + + private UserDataCache failingCache(UserDataManager manager, UUID uuid) { + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(1)))); + cache.setSharedStorageWriter(values -> { throw new IllegalStateException("write failed"); }); + cache.addChange(new UserDataChangeInt("Points", 2), true); + return cache; + } + private static void await(CountDownLatch latch) throws InterruptedException { assertTrue(latch.await(5, TimeUnit.SECONDS)); } private static final class Fixture implements AutoCloseable { @@ -412,8 +540,14 @@ private static final class Fixture implements AutoCloseable { private static final class MemoryBackend implements SqlUserBackend { final UserStorage type; final Map> rows = new ConcurrentHashMap<>(); + volatile CountDownLatch writeStarted; + volatile CountDownLatch releaseWrite; boolean open = true; MemoryBackend(UserStorage type) { this.type = type; } + void blockWrites(CountDownLatch started, CountDownLatch release) { + writeStarted = started; + releaseWrite = release; + } int points(UUID uuid) { return rows.get(uuid).get("Points").getInt(); } public UserStorage storageType() { return type; } public boolean isOpen() { return open; } @@ -433,6 +567,16 @@ public List readRow(UserStorage requested) { public void writeValues(UserStorage requested, HashMap values) { assertEquals(type, requested); if (!open) throw new IllegalStateException("closed backend"); + CountDownLatch started = writeStarted; + CountDownLatch release = releaseWrite; + if (started != null && release != null) { + started.countDown(); + try { await(release); } + catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(interrupted); + } + } rows.computeIfAbsent(uuid, ignored -> new HashMap<>()).putAll(values); } }; diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java index 7d6e66dc7..6a0427dfb 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedLegacyBackendRoutingTest.java @@ -1,7 +1,6 @@ package com.bencodez.advancedcore.tests.user; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -30,8 +29,10 @@ import com.bencodez.simpleapi.sql.Column; import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; class SharedLegacyBackendRoutingTest { @Test @@ -110,7 +111,7 @@ void directLegacyWriteIsAdmittedBeforeAReplacementCanBePublished() throws Except } @Test - void legacyAdapterUsesBoundReplacementBackendRegardlessOfPluginStorageType() { + void legacyAdapterRejectsCrossStoreWriteInsteadOfSilentlyWritingTheSharedStore() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); UserDataManager manager = new UserDataManager(plugin); when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); @@ -127,12 +128,76 @@ public void close() {} when(plugin.getUserManager().getDataManager()).thenReturn(manager); BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, () -> uuid.toString()); DataValueInt value = new DataValueInt(9); - adapter.write(UserStorage.MYSQL, "Points", value); - assertEquals(UserStorage.SQLITE, target.lastStorage); - assertSame(value, target.lastValue); + IllegalStateException failure = assertThrows(IllegalStateException.class, + () -> adapter.write(UserStorage.MYSQL, "Points", value)); + assertTrue(failure.getMessage().contains("MYSQL")); + assertTrue(failure.getMessage().contains("SQLITE")); + assertEquals(null, target.lastStorage); + assertEquals(null, target.lastValue); manager.getTimer().shutdownNow(); } + @Test + void legacyAdapterPreservesTheExplicitTargetWhenItMatchesTheSharedStore() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.getUserManager()).thenReturn(mock(com.bencodez.advancedcore.api.user.UserManager.class)); + UUID uuid = UUID.randomUUID(); + RecordingStorage target = new RecordingStorage(); + SqlUserBackend backend = new SqlUserBackend() { + public UserStorage storageType() { return UserStorage.SQLITE; } + public SqlUserStorage user(UUID requested) { assertEquals(uuid, requested); return target; } + public List enumerateUsers() { return List.of(uuid); } + public boolean isOpen() { return true; } + public void close() {} + }; + manager.bindSharedSqlBackend(backend, Runnable::run); + when(plugin.getUserManager().getDataManager()).thenReturn(manager); + try { + new BukkitSqlUserStorage(() -> plugin, uuid::toString) + .write(UserStorage.SQLITE, "Points", new DataValueInt(9)); + assertEquals(UserStorage.SQLITE, target.lastStorage); + assertEquals(9, target.lastValue.getInt()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void explicitRuntimeMaintenanceCanUseTheConverterTargetWithoutWeakeningNormalCrossStoreProtection() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + UserTable sqlite = mock(UserTable.class); + UUID uuid = UUID.randomUUID(); + SqlUserBackend backend = mock(SqlUserBackend.class); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getSQLiteUserTable()).thenReturn(sqlite); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, Runnable::run); + manager.bindSharedRuntime(runtime); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return null; + }).when(runtime).runStorageMaintenance(any(Runnable.class)); + BukkitSqlUserStorage adapter = new BukkitSqlUserStorage(() -> plugin, uuid::toString); + try { + assertThrows(IllegalStateException.class, + () -> adapter.write(UserStorage.SQLITE, "Points", new DataValueInt(1))); + + manager.runStorageMaintenance(() -> + adapter.write(UserStorage.SQLITE, "Points", new DataValueInt(2))); + + verify(runtime).runStorageMaintenance(any(Runnable.class)); + verify(sqlite).update(any(Column.class), any()); + } finally { + manager.getTimer().shutdownNow(); + } + } + private static final class RecordingStorage implements SqlUserStorage { UserStorage lastStorage; DataValue lastValue; diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java new file mode 100644 index 000000000..944836016 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java @@ -0,0 +1,99 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.HashMap; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; + +class SharedUserStorageReloadSafetyTest { + @Test + void loadAndReloadRejectBeforeAnyNativeStorageMutationWhenSharedRuntimeOwnsStorage() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(runtime.isClosed()).thenReturn(false); + manager.bindSharedRuntime(runtime); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + try { + assertThrows(IllegalStateException.class, () -> plugin.loadUserAPI(UserStorage.MYSQL)); + assertThrows(IllegalStateException.class, () -> plugin.reloadAdvancedCore(true)); + verify(plugin, never()).getOptions(); + verify(plugin, never()).getServerDataFile(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void retirementRemainsAnUnsafeReloadWindowUntilBlockingCloseCompletes() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + CompletableFuture retirement = new CompletableFuture<>(); + CountDownLatch afterRetirement = new CountDownLatch(1); + when(runtime.isClosed()).thenReturn(false); + when(runtime.closeAsync(any())).thenReturn(retirement); + manager.bindSharedRuntime(runtime); + try { + assertTrue(manager.closeSharedRuntimeAsync(afterRetirement::countDown)); + assertTrue(manager.hasSharedRuntimeLifecycle()); + assertTrue(manager.closeSharedRuntimeAsync(() -> { + throw new AssertionError("A second caller must join the existing retirement"); + })); + retirement.complete(null); + assertTrue(afterRetirement.await(5, TimeUnit.SECONDS)); + assertFalse(manager.hasSharedRuntimeLifecycle()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void conversionRunsBehindTheRuntimeMaintenanceBarrierInsteadOfTheReloadGuard() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + when(plugin.getUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + when(users.getAllKeys(UserStorage.SQLITE)).thenReturn(new HashMap<>()); + doNothing().when(plugin).loadUserAPI(any(UserStorage.class)); + doNothing().when(plugin).debug(any(String.class)); + doAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return null; + }).when(runtime).runStorageMaintenance(any(Runnable.class)); + manager.bindSharedRuntime(runtime); + try { + plugin.convertDataStorage(UserStorage.SQLITE, UserStorage.MYSQL); + + verify(runtime).runStorageMaintenance(any(Runnable.class)); + verify(plugin).loadUserAPI(UserStorage.SQLITE); + verify(plugin).loadUserAPI(UserStorage.MYSQL); + } finally { + manager.getTimer().shutdownNow(); + } + } +} From e5609310cdb5dc77056f7e1f47d9a90d9e71722d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 18:16:35 -0600 Subject: [PATCH 25/34] Harden shared user storage lifecycle --- .../advancedcore/api/player/UuidLookup.java | 8 +- .../api/user/AdvancedCoreUser.java | 19 +- .../advancedcore/api/user/UserData.java | 283 ++++++++++--- .../api/user/usercache/UserDataCache.java | 67 +++- .../api/user/usercache/UserDataManager.java | 327 +++++++++++++-- .../bukkit/runtime/BukkitRuntimePlatform.java | 26 +- .../user/runtime/BukkitUserCacheOwner.java | 3 +- .../user/storage/BukkitSqlUserBackend.java | 4 +- .../advancedcore/command/CommandLoader.java | 67 ++-- .../advancedcore/command/gui/UserGUI.java | 106 +++-- .../core/platform/RuntimePlatform.java | 19 + .../core/runtime/AdvancedCoreRuntime.java | 82 +++- .../tests/lifecycle/CoreRuntimeTest.java | 60 ++- .../tests/storage/SqlUserDataFacadeTest.java | 17 +- .../user/BukkitUserRuntimeBootstrapTest.java | 55 +++ .../user/BukkitUserRuntimeShutdownTest.java | 13 +- .../SharedCacheBindingRegressionTest.java | 376 +++++++++++++++++- .../SharedCacheCleanupPrimaryThreadTest.java | 246 ++++++++++++ .../SharedUserLifecycleRegressionTest.java | 7 + 19 files changed, 1572 insertions(+), 213 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java index 9c850fea9..297c10cea 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java @@ -179,12 +179,8 @@ public String getPlayerName(AdvancedCoreUser user, String uuid, boolean useCache cacheMapping(uuid, liveName); // Update stored PlayerName if it changed / missing - if (user != null && user.getUserData().hasData()) { - if (storedName.isEmpty() || storedName.equalsIgnoreCase("Error getting name") - || !liveName.equals(storedName)) { - user.getData().setString("PlayerName", liveName); - } - } + if (user != null && (storedName.isEmpty() || storedName.equalsIgnoreCase("Error getting name") + || !liveName.equals(storedName))) user.updateName(false); return liveName; } 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 666e04150..31d880641 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 @@ -1652,11 +1652,18 @@ public AdvancedCoreUser tempCache() { * @param force whether to force the update */ public void updateName(boolean force) { - if (getData().hasData() || force) { - String playerName = getData().getString("PlayerName", userDataFetchMode); - if (playerName == null || !playerName.equals(getPlayerName())) { - getData().setString("PlayerName", getPlayerName(), true); - } + UserData currentData = getData(); + if (!force && plugin != null && plugin.getUserManager() != null + && plugin.getUserManager().getDataManager().deferSharedStorageResult(currentData::hasData, + hasData -> updateName(currentData, false, hasData), ignored -> {})) return; + updateName(currentData, force, force || currentData.hasData()); + } + + private void updateName(UserData currentData, boolean force, boolean hasData) { + if (!hasData && !force) return; + String storedName = currentData.getString("PlayerName", userDataFetchMode); + if (storedName == null || !storedName.equals(getPlayerName())) { + currentData.setString("PlayerName", getPlayerName(), true); } } @@ -1669,4 +1676,4 @@ public void updateTempCacheWithColumns(ArrayList cols) { getUserData().updateTempCacheWithColumns(cols); } -} \ No newline at end of file +} 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 855da04a7..c978df3af 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserData.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserData.java @@ -4,8 +4,11 @@ import java.util.HashMap; import java.util.List; -import com.bencodez.advancedcore.api.user.usercache.UserDataCache; -import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChange; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeBoolean; +import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeString; import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserStorage; import com.bencodez.advancedcore.core.user.storage.SqlUserDataAccess; @@ -124,10 +127,14 @@ public int getInt(String key, int def, boolean useCache, boolean waitForCache) { UserDataFetchMode.fromBooleans(useCache, waitForCache)); } - public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mode) { - if (key == null || key.isEmpty()) { - return def; - } + public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mode) { + if (key == null || key.isEmpty()) { + return def; + } + // The shared cache belongs to exactly one physical store. Check this + // before consulting any cache layer so an explicit alternate-store read + // cannot be answered with a value from the active shared backend. + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); // 1) Temp cache if (mode.allowTempCache() && tempCache != null) { @@ -177,17 +184,21 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo user.cache(); } + if (!mode.allowStorageLookup()) { + return def; + } + } else { if (!mode.allowStorageLookup()) { return def; } - } else { - if (!mode.allowStorageLookup()) { - return def; - } - } - - // 3) Storage lookup - return sqlData.getInt(storage, key, def); + } + + // 3) Storage lookup + if (mustDeferSharedStorageAccess()) { + rejectUnavailableFreshRead(mode); + return def; + } + return sqlData.getInt(storage, key, def); } /** @@ -202,9 +213,12 @@ public ArrayList getKeys() { return getKeys(user.getPlugin().getStorageType()); } - public ArrayList getKeys(UserStorage storage) { - return sqlData.getKeys(storage); - } + public ArrayList getKeys(UserStorage storage) { + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + UserDataCache sharedCache = primaryThreadSharedCache(storage); + if (sharedCache != null) return new ArrayList<>(sharedCache.snapshot().keySet()); + return sqlData.getKeys(storage); + } /** * @deprecated Use {@link #getKeys()} or {@link #getKeys(UserStorage)} @@ -238,10 +252,13 @@ public String getString(String key, UserDataFetchMode mode) { return getString(user.getPlugin().getStorageType(), key, mode); } - public String getString(UserStorage storage, String key, UserDataFetchMode mode) { - if (key == null || key.isEmpty()) { - return ""; - } + public String getString(UserStorage storage, String key, UserDataFetchMode mode) { + if (key == null || key.isEmpty()) { + return ""; + } + // See getInt(UserStorage,...): cache contents are only valid for the + // runtime-owned store. + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); // 1) Temp cache if (mode.allowTempCache() && tempCache != null) { @@ -274,18 +291,34 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) user.cache(); } + if (!mode.allowStorageLookup()) { + return ""; + } + } else { if (!mode.allowStorageLookup()) { return ""; } - } else { - if (!mode.allowStorageLookup()) { - return ""; - } - } - - // 3) Storage lookup - return sqlData.getString(storage, key); - } + } + + // 3) Storage lookup + if (mustDeferSharedStorageAccess()) { + rejectUnavailableFreshRead(mode); + return ""; + } + return sqlData.getString(storage, key); + } + + private void rejectUnavailableFreshRead(UserDataFetchMode mode) { + if (mode == UserDataFetchMode.NO_CACHE) { + throw new IllegalStateException( + "A fresh shared user-data read cannot run on the primary thread; defer it to the user-data worker"); + } + } + + private boolean mustDeferSharedStorageAccess() { + UserDataManager dataManager = sharedDataManager(); + return dataManager != null && dataManager.mustDeferSharedStorageAccess(); + } /** * @deprecated Use {@link #getString(String, UserDataFetchMode)} @@ -343,18 +376,34 @@ public HashMap getValues() { return getValues(user.getPlugin().getStorageType()); } - public HashMap getValues(UserStorage storage) { - return convert(sqlData.readRow(storage)); - } - - public boolean hasData() { - return sqlData.hasData(user.getPlugin().getStorageType()); - } - - public void remove() { - sqlData.remove(user.getPlugin().getStorageType()); - user.clearCache(); - } + public HashMap getValues(UserStorage storage) { + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + UserDataCache sharedCache = primaryThreadSharedCache(storage); + if (sharedCache != null) return sharedCache.snapshot(); + return convert(sqlData.readRow(storage)); + } + + public boolean hasData() { + UserStorage storage = user.getPlugin().getStorageType(); + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + UserDataCache sharedCache = primaryThreadSharedCache(storage); + return sharedCache == null ? sqlData.hasData(storage) : sharedCache.hasStoredData(); + } + + public void remove() { + UserStorage storage = user.getPlugin().getStorageType(); + UserDataManager manager = sharedDataManager(); + if (manager != null && manager.hasSharedRuntime()) { + // The runtime serializes flush, delete, and cache retirement under its + // exclusive per-user gate. Deleting through the adapter and then + // clearing the cache separately can otherwise let queued work recreate + // the row after its deletion. + manager.removeUserData(java.util.UUID.fromString(user.getUUID()), storage); + return; + } + sqlData.remove(storage); + user.clearCache(); + } public void setBoolean(String key, boolean value) { setString(key, "" + value); @@ -380,7 +429,7 @@ public void setInt(UserStorage storage, final String key, final int value, boole setInt(storage, key, value, queue, false); } - public void setInt(final UserStorage storage, final String key, final int value, boolean queue, boolean async) { + public void setInt(final UserStorage storage, final String key, final int value, boolean queue, boolean async) { if (key.equals("")) { user.getPlugin().debug("No key: " + key + " to " + value); return; @@ -392,12 +441,24 @@ public void setInt(final UserStorage storage, final String key, final int value, user.getPlugin().extraDebug("PlayerData " + storage.toString() + ": Setting " + key + " to '" + value + "' for '" + user.getPlayerName() + "/" + user.getUUID() + "' Queue: " + queue); - if (user.isCached()) { - user.getCache().addChange(new UserDataChangeInt(key, value), queue); - user.getPlugin().getUserManager().onChange(user, key); - if (queue) { - return; - } + UserDataChangeInt change = new UserDataChangeInt(key, value); + if (queueSharedMutation(storage, change, queue, async)) return; + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + + if (user.isCached()) { + boolean flushImmediately = !queue && user.getPlugin().getUserManager().getDataManager() + .usesSharedSqlStorage(storage); + user.getCache().addChange(change, queue || flushImmediately); + // An immediate shared flush reports the change from its persistence + // completion callback. Keep the legacy eager callback for ordinary + // queued/cache-only changes, but do not report this direct write twice. + if (!flushImmediately) user.getPlugin().getUserManager().onChange(user, key); + if (queue || flushImmediately) { + if (flushImmediately) { + user.getCache().processChangesImmediately(async); + } + return; + } } if (async) { @@ -438,8 +499,8 @@ public void setString(UserStorage storage, final String key, final String value, setString(storage, key, value, queue, false); } - public void setString(final UserStorage storage, final String key, final String value, boolean queue, - boolean async) { + public void setString(final UserStorage storage, final String key, final String value, boolean queue, + boolean async) { if (key.equals("") && value != null) { user.getPlugin().debug("No key/value: " + key + " to " + value); return; @@ -451,12 +512,21 @@ public void setString(final UserStorage storage, final String key, final String user.getPlugin().extraDebug("PlayerData " + storage.toString() + ": Setting " + key + " to '" + value + "' for '" + user.getPlayerName() + "/" + user.getUUID() + "' Queue: " + queue); - if (user.isCached()) { - user.getCache().addChange(new UserDataChangeString(key, value), queue); - user.getPlugin().getUserManager().onChange(user, key); - if (queue) { - return; - } + UserDataChangeString change = new UserDataChangeString(key, value); + if (queueSharedMutation(storage, change, queue, async)) return; + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + + if (user.isCached()) { + boolean flushImmediately = !queue && user.getPlugin().getUserManager().getDataManager() + .usesSharedSqlStorage(storage); + user.getCache().addChange(change, queue || flushImmediately); + if (!flushImmediately) user.getPlugin().getUserManager().onChange(user, key); + if (queue || flushImmediately) { + if (flushImmediately) { + user.getCache().processChangesImmediately(async); + } + return; + } } if (async) { @@ -477,7 +547,96 @@ public void run() { } } - } + } + + /** + * A primary-thread cache miss is represented by a placeholder whose database + * snapshot is already queued on the manager worker. Mutations must join that + * generation instead of falling through to synchronous SQL. The cache's + * version fences retain this value when the delayed read publishes. + */ + private boolean queueSharedMutation(UserStorage storage, UserDataChange change, boolean queue, boolean async) { + UserDataManager manager = sharedDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(storage) || manager.isStorageMaintenanceActive()) { + return false; + } + java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); + Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, + () -> applySharedMutation(manager, user.getCache(), change, queue, false)); + if (manager.mustDeferSharedStorageAccess()) { + // Create/schedule the placeholder before the mutation task so the manager's + // single worker preserves population-before-write ordering. + user.getCache(); + return manager.deferSharedStorageWork(mutation); + } + manager.withSharedSqlStorage(uuid, storage, + () -> applySharedMutation(manager, user.getCache(), change, queue, async)); + return true; + } + + private void applySharedMutation(UserDataManager manager, UserDataCache cache, UserDataChange change, + boolean queue, boolean async) { + cache.addChange(change, true); + if (queue) { + manager.dispatchSharedStorageNotification(() -> + user.getPlugin().getUserManager().onChange(user, change.getKey())); + } else cache.processChangesImmediately(async); + } + + /** Preserve batched setter semantics through the same cache/storage generation. */ + private boolean queueSharedValues(UserStorage storage, HashMap values) { + UserDataManager manager = sharedDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(storage) || manager.isStorageMaintenanceActive()) { + return false; + } + if (values == null) return false; + if (values.isEmpty()) return true; + java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); + Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, () -> { + UserDataCache cache = user.getCache(); + for (java.util.Map.Entry entry : values.entrySet()) { + if (entry.getKey() == null || "uuid".equalsIgnoreCase(entry.getKey()) || entry.getValue() == null) continue; + cache.addChange(change(entry.getKey(), entry.getValue()), true); + } + cache.processChangesImmediately(false); + }); + if (manager.mustDeferSharedStorageAccess()) { + user.getCache(); + return manager.deferSharedStorageWork(mutation); + } + mutation.run(); + return true; + } + + private UserDataChange change(String key, DataValue value) { + if (value.isInt()) return new UserDataChangeInt(key, value.getInt()); + if (value.isBoolean()) return new UserDataChangeBoolean(key, value.getBoolean()); + return new UserDataChangeString(key, value.getString()); + } + + /** Reject a cross-store request before it can alter the shared cache generation. */ + private void ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(UserStorage storage) { + UserDataManager manager = sharedDataManager(); + if (manager != null && manager.hasSharedSqlBackend() && !manager.usesSharedSqlStorage(storage) + && !manager.isStorageMaintenanceActive()) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns another store"); + } + } + + private UserDataManager sharedDataManager() { + if (user.getPlugin() == null) return null; + UserManager userManager = user.getPlugin().getUserManager(); + return userManager == null ? null : userManager.getDataManager(); + } + + private UserDataCache primaryThreadSharedCache(UserStorage storage) { + UserDataManager manager = sharedDataManager(); + if (manager == null || !manager.usesSharedSqlStorage(storage) || !manager.mustDeferSharedStorageAccess()) { + return null; + } + return user.getCache(); + } public void setStringList(final String key, final ArrayList value) { setStringList(key, value, true); @@ -504,9 +663,11 @@ public void setValues(String key, DataValue value) { setValues(user.getPlugin().getStorageType(), values); } - public void setValues(UserStorage storage, HashMap values) { - sqlData.setValues(storage, values); - } + public void setValues(UserStorage storage, HashMap values) { + if (queueSharedValues(storage, values)) return; + ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); + sqlData.setValues(storage, values); + } public void tempCache() { tempCache = getValues(); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java index 79d1ae139..031c350d1 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataCache.java @@ -3,6 +3,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.Map.Entry; +import java.util.Objects; import java.util.Queue; import java.util.UUID; import java.util.concurrent.ConcurrentLinkedQueue; @@ -24,6 +25,7 @@ public class UserDataCache { private final UserDataManager manager; private long snapshotVersion; private long replacementVersion; + private boolean storedDataPresent; private final HashMap changedAt = new HashMap<>(); private final HashMap persistedAt = new HashMap<>(); private final HashMap inFlightValues = new HashMap<>(); @@ -99,7 +101,12 @@ ArrayList refreshInternal(boolean notify) { AdvancedCoreUser user = manager.getPlugin().getUserManager().getUser(currentUuid, false); ArrayList keys = user.getUserData().getKeys(); HashMap data = user.getUserData().getValues(); - HashMap refreshed = 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 + // VotingPlugin's dynamic VoteShopLimit keys) and layer registered defaults + // only where storage omitted a known key. + HashMap refreshed = new HashMap<>(data); for (UserDataKey dataKey : manager.getKeys()) { String key = dataKey.getKey(); keys.remove(key); @@ -113,7 +120,8 @@ ArrayList refreshInternal(boolean notify) { // 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); + published = updateSharedSnapshot(refreshed, expectedVersion, currentUuid, + refreshedStoredDataPresent); } ArrayList changedKeys = new ArrayList<>(); for (Entry entry : published.entrySet()) { @@ -121,7 +129,7 @@ ArrayList refreshInternal(boolean notify) { if (prior != null && entry.getValue() != null && !prior.toString().equals(entry.getValue().toString())) changedKeys.add(entry.getKey()); } if (notify && !changedKeys.isEmpty()) manager.getPlugin().getUserManager().onChange(user, ArrayUtils.convert(changedKeys)); - if (!keys.isEmpty()) manager.getPlugin().devDebug("Keys not cached: " + ArrayUtils.makeStringList(keys)); + if (!keys.isEmpty()) manager.getPlugin().devDebug("Caching additional keys: " + ArrayUtils.makeStringList(keys)); return changedKeys; } @@ -215,6 +223,10 @@ private void dumpNow() { public AdvancedCoreUser getUser() { return manager.getPlugin().getUserManager().getUser(uuid, false); } public synchronized boolean hasCache() { return cache != null && !cache.isEmpty(); } + public synchronized boolean hasStoredData() { return storedDataPresent; } + public synchronized HashMap snapshot() { + return cache == null ? new HashMap<>() : new HashMap<>(cache); + } public synchronized boolean hasChangesToProcess() { return cachedChanges != null && !cachedChanges.isEmpty(); } public synchronized boolean isCached(String key) { return cache != null && cache.containsKey(key); } @@ -253,6 +265,22 @@ public void processChanges() { if (notification != null) notification.run(); } + /** Flush now when blocking is allowed, otherwise preserve ordering on the cache worker. */ + public void processChangesImmediately(boolean async) { + if (async) { + processChangesAsync(); + return; + } + if (manager != null && manager.hasSharedSqlBackend()) { + if (manager.deferSharedStorageWork(() -> processChangesImmediately(false))) return; + initializeSharedStorage(); + Runnable notification = processChangesInternal(false); + if (notification != null) manager.dispatchSharedStorageNotification(notification); + return; + } + processChanges(); + } + private Runnable processChangesInternal(boolean admitted) { UUID currentUuid = null; Consumer> writer = null; @@ -313,9 +341,20 @@ private Runnable processChangesInternal(boolean admitted) { if (!values.isEmpty()) { if (writer == null) user.getUserData().setValues(values); else writer.accept(values); } persisted = true; synchronized (this) { + if (!values.isEmpty()) storedDataPresent = true; if (cache != null) { for (UserDataChange persistedChange : changes) { - cache.put(persistedChange.getKey(), persistedChange.toUserDataValue()); + Long persistedVersion = persistedMutationVersions.get(persistedChange.getKey()); + Long queuedVersion = changedAt.get(persistedChange.getKey()); + // A setter may have queued a newer value while this batch was in + // storage. Keep that visible value until its own batch succeeds. + // A public updateCache() replacement, however, is a storage snapshot + // rather than another mutation. It clears changedAt(), so reconcile + // that stale snapshot with this completed write instead of leaving a + // value visible which will never be persisted. + if (queuedVersion == null || Objects.equals(persistedVersion, queuedVersion)) { + cache.put(persistedChange.getKey(), persistedChange.toUserDataValue()); + } } } // The in-memory values now represent the successful write. Mark that @@ -386,6 +425,7 @@ public synchronized void updateCache(HashMap tempCache) { public synchronized void updateCachePreservingPending(HashMap storageValues) { HashMap refreshed = storageValues == null ? new HashMap<>() : new HashMap<>(storageValues); + storedDataPresent = !refreshed.isEmpty(); refreshed.putAll(inFlightValues); if (cachedChanges != null) for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); cache = refreshed; @@ -395,13 +435,16 @@ public synchronized void updateCachePreservingPending(HashMap public synchronized long getSharedSnapshotVersion() { return snapshotVersion; } public synchronized HashMap updateSharedSnapshot(HashMap values, - long expectedVersion) { return updateSharedSnapshot(values, expectedVersion, uuid); } + long expectedVersion) { + return updateSharedSnapshot(values, expectedVersion, uuid, values != null && !values.isEmpty()); + } private synchronized HashMap updateSharedSnapshot(HashMap values, - long expectedVersion, UUID expectedUuid) { + long expectedVersion, UUID expectedUuid, boolean snapshotStoredDataPresent) { if (cache == null || uuid == null || !uuid.equals(expectedUuid)) throw new IllegalStateException("Shared user cache changed while loading"); if (expectedVersion < 0 || expectedVersion > snapshotVersion) throw new IllegalArgumentException("Invalid cache snapshot version"); if (replacementVersion > expectedVersion) return new HashMap<>(cache); + storedDataPresent = snapshotStoredDataPresent; HashMap merged = values == null ? new HashMap<>() : new HashMap<>(values); changedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); persistedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); @@ -413,6 +456,16 @@ private synchronized HashMap updateSharedSnapshot(HashMap cachedChanges == null + || cachedChanges.stream().noneMatch(change -> key.equals(change.getKey()))); + if (cache != null && cachedChanges != null) { + for (UserDataChange change : cachedChanges) { + cache.put(change.getKey(), change.toUserDataValue()); + } + } } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java index 22e952293..822f19504 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 @@ -3,8 +3,11 @@ import java.util.ArrayList; import java.util.Objects; import java.util.Map.Entry; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; @@ -49,6 +52,8 @@ public class UserDataManager { private volatile SharedUserDataRuntime sharedRuntime; /** True from retirement admission until its native-owner callback has finished. */ private volatile boolean sharedRuntimeRetiring; + /** The one in-flight retirement, retained so lifecycle callers can await it safely. */ + private volatile CompletionStage sharedRuntimeRetirement; private final AtomicReference lastDeferredStorageFailure = new AtomicReference<>(); private final Object sharedBindingAdmission = new Object(); /** Explicit converter-only bypass for the legacy storage adapter. */ @@ -56,6 +61,16 @@ public class UserDataManager { private boolean sharedBindingTransition; private int legacyBatches; private final ReentrantReadWriteLock cacheMapLifecycle = new ReentrantReadWriteLock(true); + private final Set sharedCachePopulations = ConcurrentHashMap.newKeySet(); + private final Set completedSharedCachePopulations = ConcurrentHashMap.newKeySet(); + private final ConcurrentHashMap sharedCachePopulationStates = new ConcurrentHashMap<>(); + + private static final class SharedCachePopulationState { + private long generation; + private int activePopulations; + } + + private record SharedCachePopulation(UUID uuid, long generation, boolean deferred) {} /** Admit shared cache-map population while excluding whole-map clear/replace. */ public final T withCacheMapReadAdmission(Supplier operation) { @@ -165,6 +180,12 @@ public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { public final boolean hasSharedSqlBackend() { return sharedSqlRoute != null; } + /** Whether the active shared writer owns the requested physical store. */ + public final boolean usesSharedSqlStorage(com.bencodez.advancedcore.api.user.UserStorage storage) { + SharedSqlRoute route = sharedSqlRoute; + return route != null && route.backend().storageType() == storage; + } + /** * Run an explicit storage-maintenance operation behind the shared runtime's * write barrier. Calls made by the operation may target a non-current store @@ -190,6 +211,37 @@ public final void runStorageMaintenance(Runnable operation) { else runtime.runStorageMaintenance(guarded); } + /** + * Remove one user through the shared runtime when available, preserving the + * flush-delete-retire ordering required to avoid recreating rows. + */ + public final void removeUserData(UUID uuid, UserStorage storage) { + Objects.requireNonNull(uuid, "uuid"); + Objects.requireNonNull(storage, "storage"); + if (deferSharedStorageWork(() -> removeUserDataNow(uuid, storage))) return; + removeUserDataNow(uuid, storage); + } + + private void removeUserDataNow(UUID uuid, UserStorage storage) { + SharedUserDataRuntime runtime = sharedRuntime; + if (runtime != null && !runtime.isClosed()) { + if (!runtime.backend().storageType().equals(storage)) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns " + runtime.backend().storageType()); + } + runtime.remove(uuid); + return; + } + withSharedSqlBackend(uuid, (sharedStorage, target) -> { + if (!sharedStorage.equals(storage)) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns another store"); + } + target.delete(sharedStorage); + return null; + }); + } + /** True only inside a runtime-exclusive explicit storage maintenance action. */ public final boolean isStorageMaintenanceActive() { return storageMaintenanceDepth.get() > 0; } @@ -225,15 +277,30 @@ public final synchronized boolean hasSharedRuntimeLifecycle() { * database while a failed shared write remains queued for recovery. */ public final boolean closeSharedRuntimeAsync(Runnable afterRetirement) { + return closeSharedRuntimeAsyncCompletion(afterRetirement) != null; + } + + /** + * Start shared runtime retirement and expose its completion to the platform + * lifecycle. The database work remains on this manager's worker; callers must + * not replace or close its native owner before this stage completes. + * + * @return the active retirement stage, or {@code null} when no shared runtime + * owns storage + */ + public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable afterRetirement) { Objects.requireNonNull(afterRetirement, "afterRetirement"); SharedUserDataRuntime runtime; + CompletableFuture completion; synchronized (this) { runtime = sharedRuntime; // A second shutdown caller must not interpret an in-flight retirement as // "no runtime" and close the native provider underneath its final flush. - if (runtime == null) return sharedRuntimeRetiring; + if (runtime == null) return sharedRuntimeRetiring ? sharedRuntimeRetirement : null; sharedRuntime = null; sharedRuntimeRetiring = true; + completion = new CompletableFuture<>(); + sharedRuntimeRetirement = completion; } runtime.closeAsync(timer).whenComplete((ignored, failure) -> { if (failure != null) { @@ -242,13 +309,19 @@ public final boolean closeSharedRuntimeAsync(Runnable afterRetirement) { sharedRuntimeRetiring = false; } reportDeferredStorageFailure(failure); + completion.completeExceptionally(failure); return; } + Throwable cleanupFailure = null; try { afterRetirement.run(); } - catch (RuntimeException | Error cleanupFailure) { reportDeferredStorageFailure(cleanupFailure); } - finally { synchronized (this) { sharedRuntimeRetiring = false; } } + catch (RuntimeException | Error failureAfterRetirement) { + cleanupFailure = failureAfterRetirement; + reportDeferredStorageFailure(cleanupFailure); + } finally { synchronized (this) { sharedRuntimeRetiring = false; } } + if (cleanupFailure == null) completion.complete(null); + else completion.completeExceptionally(cleanupFailure); }); - return true; + return completion; } /** @@ -263,6 +336,9 @@ public final T withSharedSqlBackend(UUID uuid, Objects.requireNonNull(operation, "operation"); SharedSqlRoute admission = sharedSqlRoute; if (admission == null) throw new IllegalStateException("Shared SQL backend is not bound"); + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Shared user storage must run on a worker thread"); + } AtomicReference result = new AtomicReference<>(); admission.gate().accept(uuid, () -> { SharedSqlRoute current = sharedSqlRoute; @@ -277,6 +353,20 @@ public final T withSharedSqlBackend(UUID uuid, return result.get(); } + /** Run cache-facing work only while the requested physical store is still current. */ + public final void withSharedSqlStorage(UUID uuid, UserStorage expectedStorage, Runnable operation) { + Objects.requireNonNull(expectedStorage, "expectedStorage"); + Objects.requireNonNull(operation, "operation"); + withSharedSqlBackend(uuid, (currentStorage, ignored) -> { + if (currentStorage != expectedStorage) { + throw new IllegalStateException("Cannot access " + expectedStorage + + " user storage while the shared runtime owns " + currentStorage); + } + operation.run(); + return null; + }); + } + /** * Execute one complete legacy cache transition with exclusive per-user lifecycle * admission. This prevents a writer that observed the old map entry from @@ -376,14 +466,47 @@ public void addKey(UserDataKey userDataKey) { @Deprecated public void cacheUser(UUID uuid) { + cacheUser(uuid, true); + } + + private void cacheUser(UUID uuid, boolean traceDevelopmentCall) { + if (deferSharedCachePopulation(uuid, traceDevelopmentCall)) return; + cacheUserSynchronously(uuid, traceDevelopmentCall); + } + + private void cacheUserSynchronously(UUID uuid, boolean traceDevelopmentCall) { + cacheUserSynchronously(uuid, traceDevelopmentCall, true); + } + + private void cacheUserSynchronously(UUID uuid, boolean traceDevelopmentCall, boolean retryAfterRetirement) { + SharedCachePopulation population = hasSharedSqlBackend() ? beginSharedCachePopulation(uuid, false) : null; CacheRefresh refreshed = new CacheRefresh(uuid); RuntimeException runtimeFailure = null; Error errorFailure = null; + boolean[] populated = { false }; cacheMapLifecycle.readLock().lock(); - try { withSharedCacheAdmission(uuid, () -> cacheUserNow(uuid, true, refreshed)); } + try { + withSharedCacheAdmission(uuid, () -> { + if (population == null || isCurrentSharedCachePopulation(population)) { + cacheUserNow(uuid, traceDevelopmentCall, refreshed); + populated[0] = true; + } + }); + } catch (RuntimeException failure) { runtimeFailure = failure; } catch (Error failure) { errorFailure = failure; } finally { cacheMapLifecycle.readLock().unlock(); } + boolean current = population == null || finishSharedCachePopulation(population, runtimeFailure == null && errorFailure == null); + if (!current || !populated[0]) { + // A synchronous caller whose read was fenced before it began still expects + // one attempt in the newly published generation. Do not retry a read that + // already populated and was then retired by backend replacement: that would + // republish a cache after the replacement's clear phase completed. + if (retryAfterRetirement && population != null && !populated[0]) { + cacheUserSynchronously(uuid, traceDevelopmentCall, false); + } + return; + } if (runtimeFailure != null) { notifyCacheChangesAfterFailure(refreshed, runtimeFailure); throw runtimeFailure; @@ -416,24 +539,121 @@ public void cacheUser(UUID uuid, String playerName) { if (playerName != null && !playerName.isEmpty() && !plugin.getOptions().isOnlineMode()) { uuid = UUID.fromString(UuidLookup.getInstance().getUUID(playerName)); } - UUID target = uuid; - CacheRefresh refreshed = new CacheRefresh(target); - RuntimeException runtimeFailure = null; - Error errorFailure = null; - cacheMapLifecycle.readLock().lock(); - try { withSharedCacheAdmission(target, () -> cacheUserNow(target, false, refreshed)); } - catch (RuntimeException failure) { runtimeFailure = failure; } - catch (Error failure) { errorFailure = failure; } - finally { cacheMapLifecycle.readLock().unlock(); } - if (runtimeFailure != null) { - notifyCacheChangesAfterFailure(refreshed, runtimeFailure); - throw runtimeFailure; + cacheUser(uuid, false); + } + + /** + * Compatibility path for synchronous Bukkit APIs: cache population still + * happens, but its SQL read and lifecycle admission run on the manager worker. + */ + private boolean deferSharedCachePopulation(UUID uuid, boolean traceDevelopmentCall) { + if (!hasSharedSqlBackend() || Bukkit.getServer() == null || !Bukkit.isPrimaryThread()) return false; + ensureSharedCachePlaceholder(uuid); + SharedCachePopulation population = beginSharedCachePopulation(uuid, true); + if (population == null) return true; + try { + timer.execute(() -> { + CacheRefresh refreshed = new CacheRefresh(uuid); + Throwable failure = null; + boolean[] populated = { false }; + cacheMapLifecycle.readLock().lock(); + try { + withSharedCacheAdmission(uuid, () -> { + if (isCurrentSharedCachePopulation(population)) { + cacheUserNow(uuid, traceDevelopmentCall, refreshed); + populated[0] = true; + } + }); + } + catch (RuntimeException | Error caught) { failure = caught; } + finally { cacheMapLifecycle.readLock().unlock(); } + Throwable deferredFailure = failure; + boolean current = finishSharedCachePopulation(population, deferredFailure == null); + if (!current || !populated[0]) return; + dispatchSharedStorageNotification(() -> { + try { + if (deferredFailure == null) notifyCacheChanges(refreshed); + else notifyCacheChangesAfterFailure(refreshed, deferredFailure); + } catch (RuntimeException | Error notificationFailure) { + if (deferredFailure != null) deferredFailure.addSuppressed(notificationFailure); + else reportDeferredStorageFailure(notificationFailure); + } + if (deferredFailure != null) reportDeferredStorageFailure(deferredFailure); + }); + }); + } catch (RejectedExecutionException rejected) { + finishSharedCachePopulation(population, false); + reportDeferredStorageFailure(rejected); + throw rejected; } - if (errorFailure != null) { - notifyCacheChangesAfterFailure(refreshed, errorFailure); - throw errorFailure; + return true; + } + + private SharedCachePopulation beginSharedCachePopulation(UUID uuid, boolean deferred) { + SharedCachePopulationState state = sharedCachePopulationStates.computeIfAbsent(uuid, + ignored -> new SharedCachePopulationState()); + synchronized (state) { + if (deferred && !sharedCachePopulations.add(uuid)) return null; + state.activePopulations++; + return new SharedCachePopulation(uuid, state.generation, deferred); } - notifyCacheChanges(refreshed); + } + + private boolean isCurrentSharedCachePopulation(SharedCachePopulation population) { + SharedCachePopulationState state = sharedCachePopulationStates.get(population.uuid()); + if (state == null) return false; + synchronized (state) { return state.generation == population.generation(); } + } + + /** + * Complete a cache population only if it still belongs to the current cache + * generation. Retirement clears both externally visible marker sets before it + * advances that generation, so a queued old read cannot republish completion. + */ + private boolean finishSharedCachePopulation(SharedCachePopulation population, boolean success) { + SharedCachePopulationState state = sharedCachePopulationStates.get(population.uuid()); + if (state == null) return false; + boolean current; + synchronized (state) { + current = state.generation == population.generation(); + if (population.deferred() && current) sharedCachePopulations.remove(population.uuid()); + if (current) { + if (success) completedSharedCachePopulations.add(population.uuid()); + else completedSharedCachePopulations.remove(population.uuid()); + } + state.activePopulations--; + if (state.activePopulations == 0 && !completedSharedCachePopulations.contains(population.uuid())) { + sharedCachePopulationStates.remove(population.uuid(), state); + } + } + return current; + } + + /** + * Retire one manager-owned cache generation. Callers that flush or replace a + * runtime must use this instead of removing the public map entry directly. + * The expected instance prevents an old owner from retiring a replacement. + */ + public final boolean retireSharedCache(UUID uuid, UserDataCache expected) { + Objects.requireNonNull(uuid, "uuid"); + SharedCachePopulationState state = sharedCachePopulationStates.computeIfAbsent(uuid, + ignored -> new SharedCachePopulationState()); + synchronized (state) { + if (userDataCache.get(uuid) != expected) return false; + if (expected != null) userDataCache.remove(uuid, expected); + sharedCachePopulations.remove(uuid); + completedSharedCachePopulations.remove(uuid); + state.generation++; + if (state.activePopulations == 0) sharedCachePopulationStates.remove(uuid, state); + return true; + } + } + + /** Publish an empty cache generation without storage access for synchronous callers. */ + private UserDataCache ensureSharedCachePlaceholder(UUID uuid) { + cacheMapLifecycle.readLock().lock(); + try { return userDataCache.computeIfAbsent(uuid, ignored -> new UserDataCache(this, uuid)); } + finally { cacheMapLifecycle.readLock().unlock(); } } private void notifyCacheChangesAfterFailure(CacheRefresh refresh, Throwable originalFailure) { @@ -467,7 +687,7 @@ private static final class CacheRefresh { * Shared SQL writes are worker-only. Existing synchronous Bukkit clear/remove * entry points hand the complete flush-and-remove sequence to the cache worker. */ - boolean deferSharedStorageWork(Runnable task) { + public final boolean deferSharedStorageWork(Runnable task) { Objects.requireNonNull(task, "task"); if (!hasSharedSqlBackend() || Bukkit.getServer() == null || !Bukkit.isPrimaryThread()) return false; try { @@ -488,6 +708,48 @@ boolean deferSharedStorageWork(Runnable task) { return true; } + /** + * Move a legacy synchronous SQL read off the Bukkit thread and return its + * result on the platform thread. The boolean tells callers whether their + * synchronous path was deferred; a value-returning API must never substitute + * an empty or stale value while its shared-store read is pending. + */ + public final boolean deferSharedStorageResult(Supplier storageWork, Consumer success, + Consumer failure) { + Objects.requireNonNull(storageWork, "storageWork"); + Objects.requireNonNull(success, "success"); + Objects.requireNonNull(failure, "failure"); + if (!mustDeferSharedStorageAccess()) return false; + try { + timer.execute(() -> { + T result = null; + Throwable problem = null; + try { result = storageWork.get(); } + catch (RuntimeException | Error caught) { + problem = caught; + reportDeferredStorageFailure(caught); + } + T completed = result; + Throwable completedFailure = problem; + dispatchSharedStorageNotification(() -> { + if (completedFailure == null) success.accept(completed); + else failure.accept(completedFailure); + }); + }); + } catch (RejectedExecutionException rejected) { + reportDeferredStorageFailure(rejected); + throw rejected; + } + return true; + } + + /** Return a deferred storage completion to Bukkit/Folia's safe scheduler. */ + public final void dispatchSharedStorageNotification(Runnable notification) { + Objects.requireNonNull(notification, "notification"); + if (Bukkit.getServer() == null || Bukkit.isPrimaryThread()) notification.run(); + else plugin.getBukkitScheduler().runTask(plugin, notification); + } + private void reportDeferredStorageFailure(Throwable failure) { lastDeferredStorageFailure.set(failure); if (plugin != null && plugin.getLogger() != null) { @@ -538,10 +800,7 @@ private void clearCacheNow() { private void clearCacheExclusively(UUID uuid, UserDataCache cache) { cache.clearCache(); cache.dump(); - boolean removed; - cacheMapLifecycle.writeLock().lock(); - try { removed = userDataCache.remove(uuid, cache); } - finally { cacheMapLifecycle.writeLock().unlock(); } + boolean removed = retireSharedCache(uuid, cache); Consumer listener = sharedCacheRemovalListener; if (removed && listener != null) listener.accept(uuid); } @@ -562,10 +821,22 @@ public void clearNonNeededCachedUsers() { } public boolean containsKey(UUID fromString) { return userDataCache.containsKey(fromString); } - public UserDataCache getCache(UUID uuid) { cacheUserIfNeeded(uuid); return userDataCache.get(uuid); } + public UserDataCache getCache(UUID uuid) { + if (hasSharedSqlBackend() && Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + UserDataCache cache = userDataCache.get(uuid); + if (cache == null) cache = ensureSharedCachePlaceholder(uuid); + if (!completedSharedCachePopulations.contains(uuid)) cacheUser(uuid, false); + return cache; + } + cacheUserIfNeeded(uuid); + return userDataCache.get(uuid); + } public boolean isBoolean(String str) { return booleanColumns.contains(str); } public boolean isCached(UUID uuid) { return userDataCache.containsKey(uuid) && userDataCache.get(uuid).hasCache(); } public boolean isInt(String str) { return intColumns.contains(str); } + public boolean mustDeferSharedStorageAccess() { + return hasSharedSqlBackend() && Bukkit.getServer() != null && Bukkit.isPrimaryThread(); + } private void loadKeys() { addKey(new UserDataKeyString("PlayerName").setColumnType("VARCHAR(30)")); @@ -607,7 +878,7 @@ private void removeCacheExclusively(UUID uuid, boolean shared) { throw failure; } } - boolean removed = cache == null ? userDataCache.remove(uuid) != null : userDataCache.remove(uuid, cache); + boolean removed = retireSharedCache(uuid, cache); Consumer listener = sharedCacheRemovalListener; if (removed && listener != null) listener.accept(uuid); } 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 f0caaa279..8a0ac8db2 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/runtime/BukkitRuntimePlatform.java @@ -2,8 +2,12 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; +import org.bukkit.Bukkit; + import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.item.FullInventoryHandler; import com.bencodez.advancedcore.api.javascript.JavascriptEngineHandler; @@ -15,6 +19,7 @@ /** Bukkit services for the shared executor lifecycle; no duplicate owners are created. */ public final class BukkitRuntimePlatform implements RuntimePlatform { private final AdvancedCorePlugin plugin; + private volatile CompletionStage userStorageRetirement = CompletableFuture.completedFuture(null); public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); @@ -42,6 +47,11 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { })); } + @Override public CompletionStage beforeExecutorShutdownCompletion() { return userStorageRetirement; } + @Override public boolean canBlockForPreExecutorShutdown() { + return Bukkit.getServer() == null || !Bukkit.isPrimaryThread(); + } + @Override public List afterExecutorGrace() { return List.of(new Cleanup("reward handler", () -> { if (plugin.getRewardHandler() != null) plugin.getRewardHandler().shutdown(); @@ -79,12 +89,24 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { } private void closeUserStorageAfterSharedRetirement() { - if (!plugin.isLoadUserData()) return; + if (!plugin.isLoadUserData()) { + userStorageRetirement = CompletableFuture.completedFuture(null); + return; + } Runnable closeMysql = () -> { if (plugin.getOptions() != null && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) && plugin.getMysql() != null) plugin.getMysql().close(); }; UserManager users = plugin.getLoadedUserManager(); - if (users == null || !users.getDataManager().closeSharedRuntimeAsync(closeMysql)) closeMysql.run(); + if (users == null) { + closeMysql.run(); + userStorageRetirement = CompletableFuture.completedFuture(null); + return; + } + CompletionStage retirement = users.getDataManager().closeSharedRuntimeAsyncCompletion(closeMysql); + if (retirement == null) { + closeMysql.run(); + userStorageRetirement = CompletableFuture.completedFuture(null); + } else userStorageRetirement = retirement; } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bukkit/user/runtime/BukkitUserCacheOwner.java index 02d7e1e8a..0cc50852f 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 @@ -231,9 +231,8 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache != null) { cache.retireAfterSharedFlush(); - manager.getUserDataCache().remove(uuid, cache); } - cacheGates.remove(uuid); + if (manager.retireSharedCache(uuid, cache)) cacheGates.remove(uuid); } @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 7da77b013..8ca764a44 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 @@ -25,16 +25,18 @@ */ public final class BukkitSqlUserBackend implements SqlUserBackend { private final AdvancedCorePlugin plugin; + private final UserStorage storageType; private final Object sqliteOperations = new Object(); private final AtomicBoolean open = new AtomicBoolean(true); public BukkitSqlUserBackend(AdvancedCorePlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.storageType = Objects.requireNonNull(plugin.getStorageType(), "storageType"); if (storageType() == UserStorage.MYSQL) requireMysql(); else requireTable(); } - @Override public UserStorage storageType() { return plugin.getStorageType(); } + @Override public UserStorage storageType() { return storageType; } @Override public SqlUserStorage user(UUID uuid) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java index 484c2aae5..e0482c6c3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java @@ -417,27 +417,18 @@ public void execute(CommandSender sender, String[] args) { } }); - cmds.add(new CommandHandler(plugin, new String[] { "UserRemove", "(player)" }, permPrefix + ".UserRemove", + cmds.add(new CommandHandler(plugin, new String[] { "UserRemove", "(player)" }, permPrefix + ".UserRemove", "Remove User") { @Override public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cRemoving " + args[1]); - // Remove user data (DB/flatfile/etc) - AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); - user.getData().remove(); - - // Remove any cached mappings (UuidLookup maintains the name<->uuid cache now) - String uuidStr = UuidLookup.getInstance().getUUID(args[1]); - if (!isBlank(uuidStr)) { - UuidLookup.getInstance().invalidate(uuidStr); - } else { - // still invalidate by name key in case it exists - UuidLookup.getInstance().invalidate(args[1]); - } - - sendMessage(sender, "&cRemoved " + args[1]); + AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); + removeUserData(sender, args[1], user, () -> { + String uuidStr = UuidLookup.getInstance().getUUID(args[1]); + UuidLookup.getInstance().invalidate(isBlank(uuidStr) ? args[1] : uuidStr); + }); } }); @@ -448,13 +439,8 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cRemoving " + args[1]); - AdvancedCoreUser user = plugin.getUserManager().getUser(UUID.fromString(args[1])); - user.getData().remove(); - - // Clear mapping from UuidLookup (no plugin uuidNameCache anymore) - UuidLookup.getInstance().invalidate(args[1]); - - sendMessage(sender, "&cRemoved " + args[1]); + AdvancedCoreUser user = plugin.getUserManager().getUser(UUID.fromString(args[1])); + removeUserData(sender, args[1], user, () -> UuidLookup.getInstance().invalidate(args[1])); } }); @@ -573,12 +559,16 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { cmds.add(new CommandHandler(plugin, new String[] { "User", "(Player)", "ViewData" }, permPrefix + ".ViewData", "View playerdata") { - @Override - public void execute(CommandSender sender, String[] args) { - AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); - for (Entry entry : user.getData().getValues().entrySet()) { - sendMessage(sender, "&c&l" + entry.getKey() + " &c" + entry.getValue().toString()); - } + @Override + public void execute(CommandSender sender, String[] args) { + AdvancedCoreUser user = plugin.getUserManager().getUser(args[1]); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, + values -> values.forEach((key, value) -> + sendMessage(sender, "&c&l" + key + " &c" + value.toString())), + failure -> sendMessage(sender, "&cUnable to read user data; check the server log."))) return; + for (Entry entry : user.getData().getValues().entrySet()) { + sendMessage(sender, "&c&l" + entry.getKey() + " &c" + entry.getValue().toString()); + } } }); @@ -702,6 +692,27 @@ private void startStorageConversion(CommandSender sender, UserStorage from, User })); } + /** Complete destructive user removal before reporting success or clearing identity mappings. */ + private void removeUserData(CommandSender sender, String identifier, AdvancedCoreUser user, + Runnable afterRemoval) { + java.util.function.Supplier remove = () -> { + user.getData().remove(); + return Boolean.TRUE; + }; + java.util.function.Consumer succeeded = ignored -> { + afterRemoval.run(); + sender.sendMessage(MessageAPI.colorize("&cRemoved " + identifier)); + }; + java.util.function.Consumer failed = ignored -> + sender.sendMessage(MessageAPI.colorize("&cUnable to remove " + identifier + "; check the server log.")); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(remove, succeeded, failed)) return; + try { succeeded.accept(remove.get()); } + catch (RuntimeException failure) { + plugin.getLogger().severe("User removal failed (" + failure.getClass().getSimpleName() + ")"); + failed.accept(failure); + } + } + /** * Gets the basic commands. * diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java index 4a67dead4..1bbda7601 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java @@ -1,7 +1,8 @@ package com.bencodez.advancedcore.command.gui; -import java.util.ArrayList; -import java.util.HashMap; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map.Entry; import org.bukkit.Bukkit; import org.bukkit.Material; @@ -19,8 +20,9 @@ import com.bencodez.advancedcore.api.item.ItemBuilder; import com.bencodez.advancedcore.api.rewards.Reward; import com.bencodez.advancedcore.api.rewards.RewardOptions; -import com.bencodez.advancedcore.api.user.AdvancedCoreUser; -import com.bencodez.simpleapi.player.PlayerUtils; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.player.PlayerUtils; +import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.valuerequest.StringListener; import com.bencodez.simpleapi.valuerequest.ValueRequest; @@ -108,51 +110,71 @@ public void onInput(Player player, String value) { } }); - inv.addButton(new BInventoryButton(new ItemBuilder("WRITABLE_BOOK").setName("Edit Data")) { - - @Override - public void onClick(ClickEvent clickEvent) { - Player player = clickEvent.getPlayer(); - EditGUI inv = new EditGUI("Edit Data, click to change"); - final AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); - for (final String key : user.getData().getKeys()) { - String value = user.getData().getValue(key); - inv.addButton(new EditGUIButton(new ItemBuilder(Material.STONE).setName(key + " = " + value), - new EditGUIValueString(key, value) { - - @Override - public void setValue(Player player, String value) { - if (value.equals("\"\"")) { - value = ""; - } - user.getData().setString(key, value); - openUserGUI(player, playerName); - } - })); - } - - inv.openInventory(player); - } - }); + inv.addButton(new BInventoryButton(new ItemBuilder("WRITABLE_BOOK").setName("Edit Data")) { + + @Override + public void onClick(ClickEvent clickEvent) { + Player player = clickEvent.getPlayer(); + final AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, + values -> openEditData(player, playerName, user, values), + failure -> player.sendMessage("Unable to read user data; check the server log."))) return; + openEditData(player, playerName, user, user.getData().getValues()); + } + }); inv.addButton(new BInventoryButton(new ItemBuilder(Material.PAPER).setName("&cView player data")) { - @Override - public void onClick(ClickEvent clickEvent) { - AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); - for (String key : user.getData().getKeys()) { - String str = user.getData().getValue(key); - user.sendMessage("&c&l" + key + " &c" + str); - } - } - }); + @Override + public void onClick(ClickEvent clickEvent) { + AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, + values -> sendUserData(user, values), + failure -> clickEvent.getPlayer().sendMessage("Unable to read user data; check the server log."))) return; + sendUserData(user, user.getData().getValues()); + } + }); for (BInventoryButton button : extraButtons.values()) { inv.addButton(button); } - inv.openInventory(player); - } + inv.openInventory(player); + } + + /** Build inventories only after a deferred shared-store read returns to Bukkit's thread. */ + private void openEditData(Player player, String playerName, AdvancedCoreUser user, + HashMap values) { + EditGUI edit = new EditGUI("Edit Data, click to change"); + for (Entry entry : values.entrySet()) { + final String key = entry.getKey(); + String value = displayValue(entry.getValue()); + edit.addButton(new EditGUIButton(new ItemBuilder(Material.STONE).setName(key + " = " + value), + new EditGUIValueString(key, value) { + + @Override + public void setValue(Player player, String value) { + if (value.equals("\"\"")) value = ""; + user.getData().setString(key, value); + openUserGUI(player, playerName); + } + })); + } + edit.openInventory(player); + } + + private void sendUserData(AdvancedCoreUser user, HashMap values) { + for (Entry entry : values.entrySet()) { + user.sendMessage("&c&l" + entry.getKey() + " &c" + displayValue(entry.getValue())); + } + } + + private String displayValue(DataValue value) { + if (value == null) return ""; + if (value.isInt()) return String.valueOf(value.getInt()); + String string = value.getString(); + return string == null ? "" : string; + } /** * Open users GUI. @@ -189,4 +211,4 @@ public void onInput(Player player, String value) { private void setCurrentPlayer(Player player, String playerName) { PlayerUtils.setPlayerMeta(plugin, player, "UserGUI", playerName); } -} \ No newline at end of file +} diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java index 42f37f98a..c76ca9c06 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java @@ -2,6 +2,8 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.ScheduledExecutorService; /** Supplies existing executor owners and platform cleanup without exposing game APIs. */ @@ -18,6 +20,23 @@ record Cleanup(String name, Runnable action) { ScheduledExecutorService getInventoryTimer(); ScheduledExecutorService getTimeTimer(); List beforeExecutorShutdown(); + + /** + * Completion of asynchronous pre-shutdown work started by + * {@link #beforeExecutorShutdown()}. Implementations must keep blocking work + * off server threads and expose its completion here so executor retirement + * cannot overtake it. + */ + default CompletionStage beforeExecutorShutdownCompletion() { + return CompletableFuture.completedFuture(null); + } + + /** + * Whether the current lifecycle caller may wait a bounded time for pre-shutdown + * work. Bukkit's disable thread must leave database retirement to its worker. + */ + default boolean canBlockForPreExecutorShutdown() { return true; } + List afterExecutorGrace(); List afterExecutorShutdown(); void info(String message); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index 6eba089ce..f94af404d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -2,9 +2,12 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletionException; +import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -16,6 +19,7 @@ * calls; cleanup hooks are synchronous and retain their existing failure policy. */ public final class AdvancedCoreRuntime { + private static final long PRE_SHUTDOWN_WAIT_SECONDS = 5; private final RuntimePlatform platform; public AdvancedCoreRuntime(RuntimePlatform platform) { @@ -39,27 +43,28 @@ public static ExecutorGroup createExecutors() { } } - public void shutdown() { - clean(platform.beforeExecutorShutdown()); + public void shutdown() { + clean(platform.beforeExecutorShutdown()); + boolean sharedRetirementFinished = awaitCleanup(platform.beforeExecutorShutdownCompletion(), "pre-executor shutdown"); // Resolve the time-checker timer once, after the pre-shutdown actions, // just as the old lifecycle did. Other getters retain their lookup order. - ScheduledExecutorService timeTimer = platform.getTimeTimer(); - shutdown(platform.getLoginTimer()); - shutdown(platform.getTimer()); - shutdown(timeTimer); + ScheduledExecutorService timeTimer = platform.getTimeTimer(); + shutdown(platform.getLoginTimer()); + if (sharedRetirementFinished) shutdown(platform.getTimer()); + shutdown(timeTimer); shutdown(platform.getInventoryTimer()); - platform.info("Allowing background tasks to finish before shutdown"); - await(platform.getLoginTimer(), 2, TimeUnit.SECONDS); - await(platform.getTimer(), 2, TimeUnit.SECONDS); - await(timeTimer, 2, TimeUnit.SECONDS); + platform.info("Allowing background tasks to finish before shutdown"); + await(platform.getLoginTimer(), 2, TimeUnit.SECONDS); + if (sharedRetirementFinished) await(platform.getTimer(), 2, TimeUnit.SECONDS); + await(timeTimer, 2, TimeUnit.SECONDS); await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); - clean(platform.afterExecutorGrace()); - shutdownNow(platform.getLoginTimer()); - shutdownNow(platform.getTimer()); - shutdownNow(timeTimer); + clean(platform.afterExecutorGrace()); + shutdownNow(platform.getLoginTimer()); + if (sharedRetirementFinished) shutdownNow(platform.getTimer()); + shutdownNow(timeTimer); shutdownNow(platform.getInventoryTimer()); clean(platform.afterExecutorShutdown()); } @@ -74,6 +79,55 @@ private void clean(List actions) { } } + /** + * Wait only where the platform permits it and never indefinitely. A failed or + * still-running storage retirement retains its worker: it owns the queued data + * and native provider until it has either flushed successfully or reported its + * own failure/retry outcome. + */ + private boolean awaitCleanup(CompletionStage completion, String component) { + if (completion == null) return true; + var future = completion.toCompletableFuture(); + if (!platform.canBlockForPreExecutorShutdown() && !future.isDone()) { + finishDeferredCleanup(completion, component); + return false; + } + try { + if (future.isDone()) future.join(); + else future.get(PRE_SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS); + return true; + } catch (TimeoutException timeout) { + platform.cleanupFailed(component, timeout); + finishDeferredCleanup(completion, component); + return false; + } catch (CompletionException failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + platform.cleanupFailed(component, cause); + return false; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + platform.cleanupFailed(component, interrupted); + return false; + } catch (Exception failure) { + Throwable cause = failure.getCause() == null ? failure : failure.getCause(); + platform.cleanupFailed(component, cause); + return false; + } + } + + /** Retire the platform timer only after deferred storage work has actually completed. */ + private void finishDeferredCleanup(CompletionStage completion, String component) { + completion.whenComplete((ignored, failure) -> { + if (failure == null) { + shutdown(platform.getTimer()); + return; + } + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() : failure; + platform.cleanupFailed(component, cause); + }); + } + public static void shutdown(ScheduledExecutorService executor) { if (executor != null && !executor.isShutdown()) executor.shutdown(); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index cdd00efd6..40ce46697 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -6,7 +6,9 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.CompletionException; import org.junit.jupiter.api.Test; @@ -23,6 +25,7 @@ private RuntimePlatform platform() { when(platform.beforeExecutorShutdown()).thenReturn(List.of()); when(platform.afterExecutorGrace()).thenReturn(List.of()); when(platform.afterExecutorShutdown()).thenReturn(List.of()); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(true); return platform; } @@ -67,7 +70,7 @@ private ScheduledExecutorService executor(String name, List events) thro return executor; } - @Test void cleanupFailureIsReportedWithoutSkippingLaterComponents() { + @Test void cleanupFailureIsReportedWithoutSkippingLaterComponents() { RuntimePlatform platform = platform(); var events = new ArrayList(); var failure = new IllegalStateException("fixture"); @@ -78,7 +81,60 @@ private ScheduledExecutorService executor(String name, List events) thro new AdvancedCoreRuntime(platform).shutdown(); assertEquals(List.of("next", "last"), events); verify(platform).cleanupFailed("failed", failure); - } + } + + @Test void waitsForAsyncPreShutdownWorkBeforeRetiringExecutors() throws Exception { + RuntimePlatform platform = platform(); + var events = new ArrayList(); + ScheduledExecutorService timer = executor("timer", events); + CompletableFuture retiring = new CompletableFuture<>(); + when(platform.beforeExecutorShutdown()).thenReturn(List.of(new Cleanup("pre", () -> events.add("pre")))); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.getTimer()).thenReturn(timer); + var worker = java.util.concurrent.Executors.newSingleThreadExecutor(); + try { + var shutdown = worker.submit(() -> new AdvancedCoreRuntime(platform).shutdown()); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (!events.contains("pre") && System.nanoTime() < deadline) Thread.yield(); + assertEquals(List.of("pre"), events, "executor shutdown must wait for storage retirement"); + retiring.complete(null); + shutdown.get(5, TimeUnit.SECONDS); + assertTrue(events.indexOf("timer-stop") > events.indexOf("pre")); + } finally { + retiring.complete(null); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test void nonBlockingPlatformLeavesStorageWorkerAliveUntilRetirementCompletes() throws Exception { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.getTimer()).thenReturn(timer); + + assertDoesNotThrow(() -> new AdvancedCoreRuntime(platform).shutdown()); + verify(timer, never()).shutdown(); + verify(timer, never()).shutdownNow(); + retiring.complete(null); + verify(timer).shutdown(); + } + + @Test void failedRetirementLeavesStorageWorkerAvailableForRecovery() { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + retiring.completeExceptionally(new IllegalStateException("write failed")); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.getTimer()).thenReturn(timer); + + new AdvancedCoreRuntime(platform).shutdown(); + verify(timer, never()).shutdown(); + verify(timer, never()).shutdownNow(); + verify(platform).cleanupFailed(eq("pre-executor shutdown"), any(IllegalStateException.class)); + } @Test void preservesInterruptAndSkipsAlreadyFinishedExecutors() throws Exception { ScheduledExecutorService executor = mock(ScheduledExecutorService.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java index 01a4e131c..7feaba7e9 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/storage/SqlUserDataFacadeTest.java @@ -41,7 +41,8 @@ class SqlUserDataFacadeTest { assertEquals(List.of("Points"), data.getKeys(storage)); assertSame(converted, data.getValues(storage)); } - verifyNoInteractions(user); + verify(user, atLeastOnce()).getPlugin(); + verifyNoMoreInteractions(user); } @Test void everyFetchModeRetainsTempUserCacheAndStoragePrecedence() { @@ -81,9 +82,10 @@ class SqlUserDataFacadeTest { @Test void asyncWriteResolvesUuidAtExecutionAndNotifiesOnlyAfterSql() { Fixture f = new Fixture(); f.data.setInt("Points", 19, false, true); - ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); - verify(f.timer).execute(task.capture()); - verifyNoInteractions(f.table, f.manager); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + verify(f.timer).execute(task.capture()); + verifyNoInteractions(f.table); + verify(f.manager, never()).onChange(any(), anyString()); when(f.user.getUUID()).thenReturn("current-id"); task.getValue().run(); var order = inOrder(f.table, f.manager); @@ -99,9 +101,10 @@ class SqlUserDataFacadeTest { verify(f.user, never()).clearCache(); clearInvocations(f.table, f.manager); RejectedExecutionException rejected = new RejectedExecutionException("stopped"); - doThrow(rejected).when(f.timer).execute(any(Runnable.class)); - assertSame(rejected, assertThrows(RejectedExecutionException.class, () -> f.data.setInt("Points", 1, false, true))); - verifyNoInteractions(f.table, f.manager); + doThrow(rejected).when(f.timer).execute(any(Runnable.class)); + assertSame(rejected, assertThrows(RejectedExecutionException.class, () -> f.data.setInt("Points", 1, false, true))); + verifyNoInteractions(f.table); + verify(f.manager, never()).onChange(any(), anyString()); } private static final class Fixture { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java index a9ac88413..7cb9e5bcf 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeBootstrapTest.java @@ -5,16 +5,20 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; +import org.bukkit.Bukkit; +import org.bukkit.Server; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.UserStorage; @@ -23,6 +27,29 @@ import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; class BukkitUserRuntimeBootstrapTest { + @Test void sharedAdapterRejectsPrimaryThreadStorageAccess() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getMysql()).thenReturn(mysql); + UserDataManager manager = new UserDataManager(plugin); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertThrows(IllegalStateException.class, () -> manager.withSharedSqlBackend(UUID.randomUUID(), + (storage, user) -> user.readRow(storage))); + } + verify(mysql, never()).getExact(anyString()); + } finally { + CountDownLatch retired = new CountDownLatch(1); + assertTrue(manager.closeSharedRuntimeAsync(retired::countDown)); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + manager.getTimer().shutdownNow(); + } + } + @Test void bindsOnlyAfterExistingMysqlStorageIsAvailableAndDoesNotOwnItsClose() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); MySQL mysql = mock(MySQL.class); @@ -58,4 +85,32 @@ class BukkitUserRuntimeBootstrapTest { manager.getTimer().shutdownNow(); } } + + @Test void boundBackendKeepsItsInitializedStorageTypeAfterOptionsChange() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + AtomicReference configured = new AtomicReference<>(UserStorage.MYSQL); + when(plugin.getStorageType()).thenAnswer(ignored -> configured.get()); + when(plugin.getMysql()).thenReturn(mysql); + when(mysql.getExact(anyString())).thenReturn(new ArrayList<>()); + UserDataManager manager = new UserDataManager(plugin); + try { + BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); + configured.set(UserStorage.SQLITE); + UUID uuid = UUID.randomUUID(); + + manager.withSharedSqlBackend(uuid, (storage, user) -> { + assertTrue(storage == UserStorage.MYSQL); + return user.readRow(storage); + }); + + verify(mysql).getExact(uuid.toString()); + verify(plugin, never()).getSQLiteUserTable(); + } finally { + CountDownLatch retired = new CountDownLatch(1); + assertTrue(manager.closeSharedRuntimeAsync(retired::countDown)); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + manager.getTimer().shutdownNow(); + } + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java index ef231aa90..bfc1f6a0f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/BukkitUserRuntimeShutdownTest.java @@ -1,12 +1,13 @@ package com.bencodez.advancedcore.tests.user; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.junit.jupiter.api.Test; +import java.util.logging.Logger; + import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.api.user.UserManager; @@ -15,9 +16,10 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.bukkit.runtime.BukkitRuntimePlatform; import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; +import com.bencodez.advancedcore.core.runtime.AdvancedCoreRuntime; class BukkitUserRuntimeShutdownTest { - @Test void closesLegacyMysqlOnlyAfterTheSharedRouteRetires() { + @Test void runtimeShutdownWaitsForSharedRetirementBeforeClosingLegacyMysql() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); MySQL mysql = mock(MySQL.class); @@ -27,15 +29,14 @@ class BukkitUserRuntimeShutdownTest { when(options.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getLogger()).thenReturn(mock(Logger.class)); UserDataManager manager = new UserDataManager(plugin); when(users.getDataManager()).thenReturn(manager); when(plugin.getLoadedUserManager()).thenReturn(users); try { BukkitUserRuntimeBootstrap.bindAfterStorageInitialization(plugin, manager); - new BukkitRuntimePlatform(plugin).beforeExecutorShutdown().stream() - .filter(action -> action.name().equals("user storage")) - .findFirst().orElseThrow().action().run(); - verify(mysql, timeout(5000)).close(); + new AdvancedCoreRuntime(new BukkitRuntimePlatform(plugin)).shutdown(); + verify(mysql).close(); } finally { manager.getTimer().shutdownNow(); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java index ae6d81c2c..1ab67e2b0 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 @@ -9,6 +9,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; @@ -21,9 +22,12 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.bukkit.Bukkit; +import org.bukkit.Server; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -40,10 +44,69 @@ @Timeout(15) class SharedCacheBindingRegressionTest { + @Test void sharedPopulationRetainsPersistedDynamicColumns() throws Exception { + try (Fixture fixture = new Fixture()) { + var data = fixture.plugin.getUserManager().getUser(fixture.uuid, false).getUserData(); + when(data.getKeys()).thenReturn(new ArrayList<>(List.of("VoteShopLimitDaily"))); + when(data.getValues()).thenReturn(new HashMap<>(Map.of("VoteShopLimitDaily", new DataValueInt(4)))); + SharedUserDataRuntime runtime = fixture.runtime(); + + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + + assertEquals(4, cache.snapshot().get("VoteShopLimitDaily").getInt()); + runtime.close(); + } + } + @Test void noDatabaseLookupMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.NO_DB_LOOKUP); } @Test void cacheOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.CACHE_ONLY); } @Test void temporaryOnlyMissDoesNotLoadOrPopulate() { cacheMiss(UserDataFetchMode.TEMP_ONLY); } + @Test void userDataRemovalUsesTheSharedRuntimeExclusiveDeletePath() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + var users = mock(com.bencodez.advancedcore.api.user.UserManager.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + SqlUserBackend backend = mock(SqlUserBackend.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(users.getDataManager()).thenReturn(manager); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); + when(runtime.isClosed()).thenReturn(false); + when(runtime.backend()).thenReturn(backend); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedRuntime(runtime); + try { + new UserData(user).remove(); + verify(runtime).remove(uuid); + verify(user, never()).clearCache(); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test void explicitAlternateStoreReadsCannotUseTheSharedCache() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + fixture.manager.bindSharedRuntime(runtime); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + UserData data = new UserData(user); + + assertThrows(IllegalStateException.class, + () -> data.getInt(UserStorage.SQLITE, "Points", 0, UserDataFetchMode.CACHE_ONLY)); + assertThrows(IllegalStateException.class, + () -> data.getString(UserStorage.SQLITE, "PlayerName", UserDataFetchMode.CACHE_ONLY)); + verify(user, never()).getCache(); + runtime.close(); + } + } + @Test void legacyCachePopulationPublishesOnlyAfterSharedAdmission() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); UserDataManager manager = new UserDataManager(plugin); @@ -145,6 +208,39 @@ class SharedCacheBindingRegressionTest { } } + @Test void managerClearDoesNotDeadlockWithSameUserPopulation() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writing = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + cache.setSharedStorageWriter(values -> { + writing.countDown(); + try { + await(releaseWrite); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + }); + cache.addChange(new UserDataChangeInt("Points", 1), true); + var workers = Executors.newFixedThreadPool(2); + try { + var clear = workers.submit(fixture.manager::clearCache); + await(writing); + var population = workers.submit(() -> fixture.manager.cacheUser(fixture.uuid, null)); + releaseWrite.countDown(); + clear.get(5, TimeUnit.SECONDS); + population.get(5, TimeUnit.SECONDS); + assertTrue(fixture.manager.containsKey(fixture.uuid)); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + private void cacheMiss(UserDataFetchMode mode) { SqlUserBackend backend = mock(SqlUserBackend.class); UserCacheOwner owner = mock(UserCacheOwner.class); @@ -188,6 +284,273 @@ private void cacheMiss(UserDataFetchMode mode) { } } + @Test void completedBatchDoesNotOverwriteANewerQueuedCacheValue() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + var worker = Executors.newSingleThreadExecutor(); + try { + var firstBatch = worker.submit(fixture.tasks.get(0)); + await(writeStarted); + cache.addChange(new UserDataChangeInt("Points", 9), true); + assertEquals(9, cache.getCache().get("Points").getInt()); + releaseWrite.countDown(); + firstBatch.get(5, TimeUnit.SECONDS); + assertEquals(9, cache.getCache().get("Points").getInt(), + "the older completed batch must not replace the newer queued value"); + fixture.first.blockWrites(null, null); + fixture.tasks.get(1).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void immediateWriteIsPersistedAfterAnOlderQueuedBatch() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + var workers = Executors.newFixedThreadPool(2); + try { + var queued = workers.submit(fixture.tasks.get(0)); + await(writeStarted); + var immediate = workers.submit(() -> data.setInt(UserStorage.MYSQL, "Points", 9, false, false)); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (cache.getCache().get("Points").getInt() != 9 && System.nanoTime() < deadline) { + Thread.yield(); + } + assertEquals(9, cache.getCache().get("Points").getInt()); + releaseWrite.countDown(); + queued.get(5, TimeUnit.SECONDS); + immediate.get(5, TimeUnit.SECONDS); + assertEquals(9, fixture.first.points(fixture.uuid), + "the immediate write must reach storage after the older queued batch"); + } finally { + releaseWrite.countDown(); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void immediateSharedWriteReportsOneChangeAfterPersistence() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + var userManager = fixture.plugin.getUserManager(); + clearInvocations(userManager); + + new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, false, false); + + assertEquals(9, fixture.first.points(fixture.uuid)); + verify(userManager, times(1)).onChange(eq(user), any(String[].class)); + runtime.close(); + } + } + + @Test void primaryThreadImmediateSharedWriteDefersWithoutLosingOrdering() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + var userManager = fixture.plugin.getUserManager(); + clearInvocations(userManager); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, false); + + assertDoesNotThrow(() -> new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, false, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + fixture.tasks.get(fixture.tasks.size() - 1).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + var callback = org.mockito.ArgumentCaptor.forClass(Runnable.class); + verify(fixture.plugin.getBukkitScheduler()).runTask(eq(fixture.plugin), callback.capture()); + verify(userManager, never()).onChange(any(), any(String[].class)); + callback.getValue().run(); + verify(userManager, times(1)).onChange(eq(user), any(String[].class)); + runtime.close(); + } + } + + @Test void immediateWriteToAnotherStoreDoesNotUseTheSharedWriter() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + + assertThrows(IllegalStateException.class, + () -> new UserData(user).setInt(UserStorage.SQLITE, "Points", 9, false, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid), + "an explicit alternate-store write must not be redirected to the shared backend"); + assertFalse(cache.getCache().containsKey("Points"), + "a rejected cross-store write must not leave an unpersisted shared-cache value behind"); + + assertThrows(IllegalStateException.class, + () -> new UserData(user).setInt(UserStorage.SQLITE, "Queued", 11, true, false)); + assertFalse(cache.hasChangesToProcess(), + "a queued cross-store write must be rejected before it reaches the active shared writer"); + runtime.close(); + } + } + + @Test void primaryThreadPendingPopulationQueuesMutationBeforePublishingTheReadSnapshot() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.isCached()).thenAnswer(ignored -> fixture.manager.isCached(fixture.uuid)); + when(user.getCache()).thenAnswer(ignored -> fixture.manager.getCache(fixture.uuid)); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, true, true, false); + + assertDoesNotThrow(() -> new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, true, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid), "the primary thread must not write SQL"); + assertEquals(2, fixture.tasks.size(), "population must be queued before mutation admission"); + assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).getCache().containsKey("Points"), + "the primary thread must not enter the shared cache mutation gate"); + + fixture.tasks.get(0).run(); + assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).getCache().containsKey("Points")); + fixture.tasks.get(1).run(); + assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), + "read publication must retain the queued mutation"); + assertEquals(3, fixture.tasks.size()); + fixture.tasks.get(2).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + runtime.close(); + } + } + + @Test void workerMutationCannotBypassAPendingPrimaryThreadPopulation() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.getCache()).thenAnswer(ignored -> fixture.manager.getCache(fixture.uuid)); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, false); + + fixture.manager.getCache(fixture.uuid); + new UserData(user).setInt(UserStorage.MYSQL, "Points", 12, true, false); + + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + fixture.tasks.get(0).run(); + assertEquals(12, fixture.first.points(fixture.uuid)); + assertEquals(12, fixture.manager.getUserDataCache().get(fixture.uuid).snapshot().get("Points").getInt()); + runtime.close(); + } + } + + @Test void completedBatchReconcilesATemporaryCacheSnapshotWithoutPersistingTheSnapshot() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + data.updateTempCacheWithColumns(new ArrayList<>(List.of(new Column("Points", new DataValueInt(3))))); + var worker = Executors.newSingleThreadExecutor(); + try { + var firstBatch = worker.submit(fixture.tasks.get(0)); + await(writeStarted); + + data.updateCacheWithTemp(); + assertEquals(3, cache.getCache().get("Points").getInt(), + "the temporary storage snapshot is visible while the earlier write is in flight"); + + releaseWrite.countDown(); + firstBatch.get(5, TimeUnit.SECONDS); + + assertEquals(7, fixture.first.points(fixture.uuid)); + assertEquals(7, cache.getCache().get("Points").getInt(), + "the completed queued write must reconcile the stale temporary snapshot"); + assertFalse(cache.hasChangesToProcess(), + "a read snapshot must not become an automatic persistence request"); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + + @Test void temporarySnapshotCannotEraseANewerQueuedMutationFence() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.blockWrites(writeStarted, releaseWrite); + cache.addChange(new UserDataChangeInt("Points", 7), true); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + data.updateTempCacheWithColumns(new ArrayList<>(List.of(new Column("Points", new DataValueInt(3))))); + var worker = Executors.newSingleThreadExecutor(); + try { + var firstBatch = worker.submit(fixture.tasks.get(0)); + await(writeStarted); + cache.addChange(new UserDataChangeInt("Points", 9), true); + data.updateCacheWithTemp(); + releaseWrite.countDown(); + firstBatch.get(5, TimeUnit.SECONDS); + + assertEquals(9, cache.getCache().get("Points").getInt(), + "the queued mutation must remain visible after the older batch completes"); + fixture.first.blockWrites(null, null); + fixture.tasks.get(1).run(); + assertEquals(9, fixture.first.points(fixture.uuid)); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + runtime.close(); + } + } + @Test void joinStyleCacheCreationAfterStartupUsesTheSelectedWriter() throws Exception { try (Fixture fixture = new Fixture()) { SharedUserDataRuntime runtime = fixture.runtime(); @@ -492,6 +855,12 @@ private void cacheMiss(UserDataFetchMode mode) { assertEquals(21, fixture.first.points(fixture.uuid)); assertFalse(fixture.manager.containsKey(fixture.uuid)); + Field completedField = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completedField.setAccessible(true); + @SuppressWarnings("unchecked") + Set completed = (Set) completedField.get(fixture.manager); + assertFalse(completed.contains(fixture.uuid), + "a callback-driven removal must not be followed by a stale completed-population marker"); runtime.close(); } } @@ -513,12 +882,12 @@ private static final class Fixture implements AutoCloseable { final BukkitUserCacheOwner owner; final MemoryBackend first = new MemoryBackend(UserStorage.MYSQL); final List tasks = new CopyOnWriteArrayList<>(); + final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); Fixture() throws Exception { when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); manager = spy(new UserDataManager(plugin)); manager.getTimer().shutdownNow(); - ScheduledExecutorService timer = mock(ScheduledExecutorService.class); Field field = UserDataManager.class.getDeclaredField("timer"); field.setAccessible(true); field.set(manager, timer); @@ -526,6 +895,11 @@ private static final class Fixture implements AutoCloseable { tasks.add(call.getArgument(0, Runnable.class)); return mock(ScheduledFuture.class); }); + doAnswer(call -> { + tasks.add(call.getArgument(0, Runnable.class)); + return null; + }).when(timer).execute(any(Runnable.class)); + when(plugin.getTimer()).thenReturn(timer); var data = plugin.getUserManager().getUser(uuid, false).getUserData(); when(data.getKeys()).thenAnswer(ignored -> new ArrayList()); when(data.getValues()).thenAnswer(ignored -> new HashMap()); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index 2aceb4b40..c22842acf 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -1,20 +1,31 @@ package com.bencodez.advancedcore.tests.user; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.logging.Level; import java.util.logging.Logger; @@ -24,12 +35,141 @@ import org.mockito.ArgumentCaptor; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserCacheOwner; import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.data.DataValueInt; class SharedCacheCleanupPrimaryThreadTest { + @Test + void primaryThreadBulkReadsUseTheCompletedSharedCacheSnapshot() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + UUID uuid = UUID.randomUUID(); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCachePreservingPending(new HashMap<>(Map.of("Points", new DataValueInt(7)))); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); + when(user.getCache()).thenReturn(cache); + UserData data = new UserData(user); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertEquals(Set.of("Points"), Set.copyOf(data.getKeys())); + assertEquals(7, data.getValues().get("Points").getInt()); + assertTrue(data.hasData()); + IllegalStateException intFailure = assertThrows(IllegalStateException.class, + () -> data.getInt(UserStorage.MYSQL, "Points", -1, + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE)); + IllegalStateException stringFailure = assertThrows(IllegalStateException.class, + () -> data.getString(UserStorage.MYSQL, "PlayerName", + com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE)); + assertTrue(intFailure.getMessage().contains("defer")); + assertTrue(stringFailure.getMessage().contains("defer")); + verify(backend, never()).user(any(UUID.class)); + } + manager.getTimer().shutdownNow(); + } + + @Test + void primaryThreadNameUpdateDefersExistenceCheckBeforeReadingOrWriting() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserData data = mock(UserData.class); + Field pluginField = AdvancedCoreUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(user, plugin); + Field fetchMode = AdvancedCoreUser.class.getDeclaredField("userDataFetchMode"); + fetchMode.setAccessible(true); + fetchMode.set(user, com.bencodez.advancedcore.api.user.UserDataFetchMode.DEFAULT); + when(user.getPlugin()).thenReturn(plugin); + when(user.getData()).thenReturn(data); + when(user.getPlayerName()).thenReturn("CurrentName"); + when(data.hasData()).thenReturn(true); + when(data.getString(eq("PlayerName"), any())).thenReturn("OldName"); + doCallRealMethod().when(user).updateName(false); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + ArgumentCaptor storageTask = ArgumentCaptor.forClass(Runnable.class); + assertDoesNotThrow(() -> user.updateName(false)); + verify(worker).execute(storageTask.capture()); + verify(data, never()).hasData(); + verify(data, never()).getString(any(), any()); + storageTask.getValue().run(); + verify(data).hasData(); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getBukkitScheduler()).runTask(eq(plugin), callback.capture()); + callback.getValue().run(); + verify(data).getString("PlayerName", com.bencodez.advancedcore.api.user.UserDataFetchMode.DEFAULT); + verify(data).setString("PlayerName", "CurrentName", true); + } + } + + @Test + void primaryThreadStorageResultIsReadOnTheWorkerAndDeliveredBackToBukkit() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + AtomicBoolean read = new AtomicBoolean(); + AtomicBoolean delivered = new AtomicBoolean(); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + assertTrue(manager.deferSharedStorageResult(() -> { + read.set(true); + return "row"; + }, value -> { + assertEquals("row", value); + delivered.set(true); + }, failure -> { throw new AssertionError(failure); })); + verify(worker).execute(task.capture()); + assertFalse(read.get()); + task.getValue().run(); + assertTrue(read.get()); + assertFalse(delivered.get()); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getBukkitScheduler()).runTask(eq(plugin), callback.capture()); + callback.getValue().run(); + assertTrue(delivered.get()); + } + manager.getTimer().shutdownNow(); + } + @Test void acceptedPrimaryThreadCleanupFailureIsLoggedAndRetained() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); @@ -63,6 +203,112 @@ void acceptedPrimaryThreadCleanupFailureIsLoggedAndRetained() throws Exception { } } + @Test + void primaryThreadCachePopulationIsQueuedAndGetCacheReturnsAPopulationPlaceholder() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> { + throw new AssertionError("primary thread must not admit cache storage work"); + }); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertDoesNotThrow(() -> manager.cacheUser(uuid, null)); + verify(worker).execute(any(Runnable.class)); + assertNotNull(manager.getCache(uuid)); + verify(worker, times(1)).execute(any(Runnable.class)); + } + } + + @Test + void removingCompletedPlaceholderAllowsAReplacementPopulation() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + Field completed = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completed.setAccessible(true); + @SuppressWarnings("unchecked") + Set completedPopulations = (Set) completed.get(manager); + completedPopulations.add(uuid); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.removeCache(uuid, null); + verify(worker).execute(task.capture()); + task.getValue().run(); + assertFalse(completedPopulations.contains(uuid)); + assertNotNull(manager.getCache(uuid)); + verify(worker, times(2)).execute(any(Runnable.class)); + } + } + + @Test + void runtimeOwnerRetirementClearsPopulationMarkersAndFencesAnOldWorker() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + manager.getUserDataCache().put(uuid, cache); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + Field populations = UserDataManager.class.getDeclaredField("sharedCachePopulations"); + populations.setAccessible(true); + Field completed = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completed.setAccessible(true); + @SuppressWarnings("unchecked") + Set inFlight = (Set) populations.get(manager); + @SuppressWarnings("unchecked") + Set completedPopulations = (Set) completed.get(manager); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + manager.cacheUser(uuid, null); + verify(worker).execute(task.capture()); + assertTrue(inFlight.contains(uuid)); + + new BukkitUserCacheOwner(manager).clearAfterFlush(); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + assertFalse(inFlight.contains(uuid)); + assertFalse(completedPopulations.contains(uuid)); + + task.getValue().run(); + assertFalse(manager.getUserDataCache().containsKey(uuid)); + assertFalse(completedPopulations.contains(uuid), + "a retired population must not republish completion for a replacement cache"); + verify(cache).retireAfterSharedFlush(); + verifyNoMoreInteractions(cache); + } + } + @Test void rejectedPrimaryThreadCleanupIsReportedWithoutRunningStorageWork() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); 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 38bf514e0..3dc3f3c70 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 @@ -279,6 +279,13 @@ 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 -> { + UUID cachedUuid = call.getArgument(0); + UserDataCache expected = call.getArgument(1); + if (caches.get(cachedUuid) != expected) return false; + if (expected != null) caches.remove(cachedUuid, expected); + return true; + }); ScheduledExecutorService timer = mock(ScheduledExecutorService.class); when(manager.getTimer()).thenReturn(timer); when(timer.schedule(any(Runnable.class), anyLong(), any(TimeUnit.class))).thenAnswer(call -> { From c888f247b97e39a1fe99c93692720251551b4cb9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:28:24 -0600 Subject: [PATCH 26/34] Harden shared user replay lifecycle --- .../advancedcore/api/player/UuidLookup.java | 5 +- .../advancedcore/api/rewards/Reward.java | 145 +++++++++++++++--- .../api/rewards/RewardOptions.java | 31 +++- .../api/user/AdvancedCoreUser.java | 60 ++++++-- .../core/runtime/AdvancedCoreRuntime.java | 2 +- .../api/rewards/RewardOptionsTest.java | 40 +++++ .../tests/lifecycle/CoreRuntimeTest.java | 3 +- .../rewards/RewardAsyncInjectionTest.java | 121 +++++++++++++++ .../tests/user/AdvancedCoreUserTest.java | 24 ++- 9 files changed, 380 insertions(+), 51 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java index 297c10cea..35b9e4a1d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/player/UuidLookup.java @@ -180,7 +180,10 @@ public String getPlayerName(AdvancedCoreUser user, String uuid, boolean useCache // Update stored PlayerName if it changed / missing if (user != null && (storedName.isEmpty() || storedName.equalsIgnoreCase("Error getting name") - || !liveName.equals(storedName))) user.updateName(false); + || !liveName.equals(storedName))) { + user.setPlayerName(liveName); + user.updateName(false); + } return liveName; } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index 5ec245f33..8b8e9802e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java @@ -506,6 +506,7 @@ public static ReplayState replayStateFor(RewardOptions options) { if (options.getAsyncReplayCheckpointConsumer() != null) { replayState.setCheckpointConsumer(options.getAsyncReplayCheckpointConsumer()); } + replayState.captureLivePlayerState(options); return replayState; } @@ -1005,7 +1006,10 @@ public static int completedLegacyActions(ReplayState replayState, HashMap replayMetadata = new HashMap<>(); private final boolean legacyCheckpoint; private final boolean restoredCheckpoint; + private boolean livePlayerStateSet; + private boolean livePlayerOnline; + private boolean livePlayerVanished; private Consumer checkpointConsumer; private ReplayState(Map initial) { this(initial, null, false); } private ReplayState(Map initial, Map initialFingerprints, @@ -1122,6 +1132,15 @@ private synchronized int highestCompletedCount() { private synchronized void setCheckpointConsumer(Consumer consumer) { checkpointConsumer = consumer; } + private synchronized void captureLivePlayerState(RewardOptions options) { + if (!options.isLivePlayerStateSet()) return; + livePlayerStateSet = true; + livePlayerOnline = options.isOnline(); + livePlayerVanished = options.isLivePlayerVanished(); + } + private synchronized void applyLivePlayerState(RewardOptions options) { + if (livePlayerStateSet) options.captureLivePlayerState(livePlayerOnline, livePlayerVanished); + } private synchronized boolean hasCheckpointConsumer() { return checkpointConsumer != null; } @@ -1560,7 +1579,17 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R || checkTimed(user, rewardOptions.getPlaceholders()))) { return CompletableFuture.completedFuture(null); } - if (!rewardOptions.isOnlineSet()) rewardOptions.setOnline(user.isOnline()); + RewardOptions stableOptions = rewardOptions; + return requestOnServerThread(user, () -> evaluateLiveRewardDecision(user, stableOptions)) + .thenCompose(decision -> finishRewardAsync(user, stableOptions, decision)); + } + + private CompletionStage evaluateLiveRewardDecision(AdvancedCoreUser user, + RewardOptions rewardOptions) { + boolean liveOnline = rewardOptions.isLivePlayerStateSet() ? rewardOptions.isOnline() : user.isOnline(); + boolean vanished = plugin.getOptions().isTreatVanishAsOffline() + && (rewardOptions.isLivePlayerStateSet() ? rewardOptions.isLivePlayerVanished() : user.isVanished()); + if (!rewardOptions.isLivePlayerStateSet()) rewardOptions.captureLivePlayerState(liveOnline, vanished); for (RewardPlaceholderHandle handle : plugin.getRewardHandler().getPlaceholders()) { if (handle.isPreProcess()) rewardOptions.addPlaceholder(handle.getKey(), handle.getValue(this, user)); } @@ -1572,7 +1601,10 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R try { if (!inject.onRequirementRequest(this, user, getConfig().getConfigData(), rewardOptions)) { canGive = false; - if (!inject.isAllowReattempt()) return CompletableFuture.completedFuture(null); + if (!inject.isAllowReattempt()) { + return CompletableFuture.completedFuture( + new LiveRewardDecision(false, false, vanished, false, true)); + } allowOffline = true; } } catch (Exception e) { @@ -1586,21 +1618,27 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R } } } - if (plugin.getOptions().isPauseRewards() || (plugin.getOptions().isTreatVanishAsOffline() && user.isVanished())) { - checkRewardFile(); - preserveReplayState(rewardOptions); - return deferRewardAsync(user, rewardOptions); + boolean verifyOnline = !rewardOptions.isOnline() || rewardOptions.getServer() != null; + boolean liveOffline = verifyOnline && !liveOnline; + return CompletableFuture.completedFuture( + new LiveRewardDecision(canGive, allowOffline, vanished, liveOffline, false)); + } + + private CompletionStage finishRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions, + LiveRewardDecision decision) { + if (decision.terminalDenied()) return CompletableFuture.completedFuture(null); + boolean vanished = decision.vanished(); + if (plugin.getOptions().isPauseRewards() || vanished) { + return deferRewardWithoutBlockingOwner(user, rewardOptions); } - if (((((!rewardOptions.isOnline() || rewardOptions.getServer() != null) && !user.isOnline()) || allowOffline) - && (!isForceOffline() && !rewardOptions.isForceOffline()))) { + if ((decision.liveOffline() || decision.allowOffline()) && !isForceOffline() + && !rewardOptions.isForceOffline()) { if (rewardOptions.isGiveOffline()) { - checkRewardFile(); - preserveReplayState(rewardOptions); - return deferRewardAsync(user, rewardOptions); + return deferRewardWithoutBlockingOwner(user, rewardOptions); } return CompletableFuture.completedFuture(null); } - if (canGive || isForceOffline() || rewardOptions.isForceOffline()) { + if (decision.canGive() || isForceOffline() || rewardOptions.isForceOffline()) { plugin.debug(name + ": Passed requirements, attempting to give to " + user.getPlayerName() + "/" + user.getUUID()); return giveRewardUserAsync(user, rewardOptions.getPlaceholders(), rewardOptions); @@ -1608,6 +1646,50 @@ private CompletionStage giveRewardAsyncOffPrimary(AdvancedCoreUser user, R return CompletableFuture.completedFuture(null); } + private CompletionStage deferRewardWithoutBlockingOwner(AdvancedCoreUser user, + RewardOptions rewardOptions) { + if (rewardOptions.isTimedQueueReplay() + || (isDurableReplay(rewardOptions) && rewardOptions.getAsyncReplayCheckpointConsumer() == null)) { + return deferRewardAsync(user, rewardOptions); + } + return continueOffServerThread(() -> { + checkRewardFile(); + preserveReplayState(rewardOptions); + return deferRewardAsync(user, rewardOptions); + }); + } + + private record LiveRewardDecision(boolean canGive, boolean allowOffline, boolean vanished, boolean liveOffline, + boolean terminalDenied) { } + + private CompletionStage continueOffServerThread(Supplier> request) { + CompletableFuture> handoff = new CompletableFuture<>(); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + CompletableFuture result = new CompletableFuture<>(); + if (!handoff.complete(result)) return; + try { + CompletionStage stage = request.get(); + if (stage == null) { + result.completeExceptionally( + new IllegalStateException("Reward continuation returned no completion stage")); + return; + } + stage.whenComplete((value, failure) -> { + if (failure == null) result.complete(value); + else result.completeExceptionally(failure); + }); + } catch (Throwable failure) { + result.completeExceptionally(failure); + } + }); + } catch (Throwable failure) { + handoff.completeExceptionally(failure); + } + return handoff.orTimeout(getServerThreadDispatchTimeoutMillis(), TimeUnit.MILLISECONDS) + .thenCompose(stage -> stage); + } + private CompletionStage deferRewardAsync(AdvancedCoreUser user, RewardOptions rewardOptions) { if (rewardOptions.isTimedQueueReplay()) { return CompletableFuture.failedFuture( @@ -1649,8 +1731,8 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R /** * Asynchronously gives a reward to a user when an injection opts into the - * asynchronous API. Preparation remains on the calling thread; only the - * opted-in injection chain is asynchronous. + * asynchronous API. Player-dependent preparation is handed to the player's + * owning scheduler before the opted-in injection chain continues. * * @param user receiving user * @param phs placeholders @@ -1660,12 +1742,14 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { ReplayState replayState = replayStateFor(rewardOptions); + List orderedRewards = orderedInjectedRewards(); + String registryFingerprint = injectionRegistryFingerprint(orderedRewards); String replayKey = rewardOptions.getAsyncReplayKey(); if (replayKey == null) { String parentKey = ACTIVE_REPLAY_KEY.get(); replayKey = parentKey == null ? getRewardName() : parentKey + "/" + getRewardName(); } - if (!replayState.matchesRegistryFingerprint(injectionRegistryFingerprint(orderedInjectedRewards()))) { + if (!replayState.matchesRegistryFingerprint(registryFingerprint)) { return CompletableFuture.failedFuture(new IncompatibleReplayCheckpointException(replayKey)); } String occurrenceId = rewardOptions.getAsyncReplayOccurrenceId(); @@ -1678,6 +1762,16 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< occurrenceId = UUID.randomUUID().toString(); } } + final String stableReplayKey = replayKey; + final String stableOccurrenceId = occurrenceId; + return requestOnServerThread(user, + () -> prepareAndGiveRewardUserAsync(user, phs, rewardOptions, replayState, stableReplayKey, + stableOccurrenceId, registryFingerprint, !orderedRewards.isEmpty())); + } + + private CompletionStage prepareAndGiveRewardUserAsync(AdvancedCoreUser user, HashMap phs, + RewardOptions rewardOptions, ReplayState replayState, String replayKey, String occurrenceId, + String registryFingerprint, boolean hasInjections) { final HashMap placeholders; try { placeholders = prepareRewardUser(user, phs); @@ -1685,14 +1779,15 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< return CompletableFuture.failedFuture(throwable); } if (placeholders == null) { - if (isDurableReplay(rewardOptions) || hasPersistedReplayCheckpoint(rewardOptions)) { - return CompletableFuture.failedFuture( - new IllegalStateException("Player became unavailable before persisted reward replay")); - } - return CompletableFuture.completedFuture(null); - } - return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, replayKey, - occurrenceId) + IllegalStateException unavailable = + new IllegalStateException("Player became unavailable before asynchronous reward delivery"); + if (!hasInjections) return CompletableFuture.failedFuture(unavailable); + replayState.setRegistryFingerprint(replayKey, registryFingerprint); + return CompletableFuture.failedFuture(new RewardReplayFailure(replayState, + phs == null ? new HashMap<>() : phs, unavailable)); + } + return giveInjectedRewardsAsync(user, placeholders, rewardOptions.getCompletedAsyncInjections(), replayState, + replayKey, occurrenceId) .thenRun(() -> plugin.debug("Gave " + user.getPlayerName() + " reward " + name)); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java index bcda82489..4f248668d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/RewardOptions.java @@ -27,9 +27,16 @@ public class RewardOptions { private boolean online = true; - @Getter - @Setter - private boolean onlineSet = false; + @Getter + @Setter + private boolean onlineSet = false; + + /** Server-thread player state captured before a replay moves to storage work. */ + @Getter + private boolean livePlayerStateSet; + + @Getter + private boolean livePlayerVanished; private HashMap placeholders = new HashMap<>(); @@ -175,11 +182,18 @@ public RewardOptions setIgnoreRequirements(boolean ignoreRequirements) { return this; } - public RewardOptions setOnline(boolean online) { - this.online = online; - this.onlineSet = true; - return this; - } + public RewardOptions setOnline(boolean online) { + this.online = online; + this.onlineSet = true; + return this; + } + + public RewardOptions captureLivePlayerState(boolean online, boolean vanished) { + setOnline(online); + livePlayerStateSet = true; + livePlayerVanished = vanished; + return this; + } public RewardOptions setPlaceholders(HashMap placeholders) { this.placeholders = placeholders; @@ -245,6 +259,7 @@ RewardOptions copyForNestedDispatch(String replayKey) { if (forceOffline) copy.forceOffline(); if (!useDefaultWorlds) copy.disableDefaultWorlds(); if (onlineSet) copy.setOnline(online); + if (livePlayerStateSet) copy.captureLivePlayerState(online, livePlayerVanished); if (!server.isEmpty()) copy.setServer(server); copy.setAsyncReplayState(asyncReplayState); copy.setAsyncReplayRegistryFingerprints(new HashMap<>(asyncReplayRegistryFingerprints)); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java index a49f85631..4b9dc5663 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/AdvancedCoreUser.java @@ -51,6 +51,7 @@ import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.advancedcore.api.rewards.RewardOptions; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.simpleapi.messages.actionbar.ActionBar; import com.bencodez.simpleapi.player.PlayerUtils; @@ -1033,8 +1034,16 @@ public void cacheIfNeeded() { * Checks and processes delayed/timed rewards. */ public void checkDelayedTimedRewards() { + checkDelayedTimedRewards(null); + } + + private void checkDelayedTimedRewards(ReplayPlayerState capturedState) { + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + if (manager.deferSharedStorageWork(() -> checkDelayedTimedRewards(state))) return; + } plugin.debug("Checking timed/delayed for " + getPlayerName()); - if (deferSharedReplayWork(this::checkDelayedTimedRewards)) return; HashMap timed = getTimedRewards(); for (Entry entry : timed.entrySet()) { long time = entry.getValue(); @@ -1058,6 +1067,8 @@ public void checkDelayedTimedRewards() { } RewardOptions replayOptions = new RewardOptions().setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholders)); + if (capturedState != null) replayOptions.captureLivePlayerState( + capturedState.online(), capturedState.vanished()); replayOptions.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); replayOptions.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); replayOptions.setAsyncReplayRegistryFingerprints(queuedReplay.asyncReplayRegistryFingerprints); @@ -1102,18 +1113,26 @@ public void checkDelayedTimedRewards() { * Check offline rewards. */ public void checkOfflineRewards() { + checkOfflineRewards(null); + } + + private void checkOfflineRewards(ReplayPlayerState capturedState) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); return; } - if (deferSharedReplayWork(this::checkOfflineRewards)) return; + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + if (manager.deferSharedStorageWork(() -> checkOfflineRewards(state))) return; + } if (isCheckWorld()) { setCheckWorld(false); } - dispatchOfflineRewards(false); + dispatchOfflineRewards(false, capturedState); } - private void dispatchOfflineRewards(boolean force) { + private void dispatchOfflineRewards(boolean force, ReplayPlayerState capturedState) { ArrayList rewards = new ArrayList<>(getOfflineRewards()); for (String rewardEntry : rewards) { if (rewardEntry == null || rewardEntry.equals("null")) { @@ -1134,6 +1153,8 @@ private void dispatchOfflineRewards(boolean force) { RewardOptions options = new RewardOptions().setOnline(false).setCheckTimed(false) .withPlaceHolder(ArrayUtils.fromString(placeholderStr)); + if (capturedState != null) options.captureLivePlayerState( + capturedState.online(), capturedState.vanished()); if (force) options.setGiveOffline(false).forceOffline(); options.setCompletedAsyncInjections(queuedReplay.completedAsyncInjections); options.setAsyncReplayProgress(queuedReplay.asyncReplayProgress); @@ -1440,23 +1461,36 @@ public AdvancedCoreUser userDataFetechMode(UserDataFetchMode mode) { * Forces running of offline rewards without processing checks. */ public void forceRunOfflineRewards() { + forceRunOfflineRewards(null); + } + + private void forceRunOfflineRewards(ReplayPlayerState capturedState) { if (!plugin.getOptions().isProcessRewards()) { plugin.debug("Processing rewards is disabled"); return; } - if (deferSharedReplayWork(this::forceRunOfflineRewards)) return; + UserDataManager manager = sharedReplayDataManager(); + if (capturedState == null && manager != null && manager.mustDeferSharedStorageAccess()) { + ReplayPlayerState state = captureReplayPlayerState(); + if (manager.deferSharedStorageWork(() -> forceRunOfflineRewards(state))) return; + } setCheckWorld(false); - dispatchOfflineRewards(true); + dispatchOfflineRewards(true, capturedState); } - /** Keep shared-SQL replay reads and durable migrations off Bukkit's primary thread. */ - private boolean deferSharedReplayWork(Runnable replayWork) { + private ReplayPlayerState captureReplayPlayerState() { + boolean vanished = plugin.getOptions().isTreatVanishAsOffline() && isVanished(); + return new ReplayPlayerState(isOnline(), vanished); + } + + private UserDataManager sharedReplayDataManager() { UserManager users = plugin.getUserManager(); - return users != null && users.getDataManager() != null - && users.getDataManager().deferSharedStorageWork(replayWork); + return users == null ? null : users.getDataManager(); } + private record ReplayPlayerState(boolean online, boolean vanished) { } + /** * Gets the user data cache. * @@ -3041,9 +3075,11 @@ public void updateName(boolean force) { private void updateName(UserData currentData, boolean force, boolean hasData) { if (!hasData && !force) return; + String resolvedName = getPlayerName(); + if (resolvedName == null || resolvedName.isBlank()) return; String storedName = currentData.getString("PlayerName", userDataFetchMode); - if (storedName == null || !storedName.equals(getPlayerName())) { - currentData.setString("PlayerName", getPlayerName(), true); + if (storedName == null || !storedName.equals(resolvedName)) { + currentData.setString("PlayerName", resolvedName, true); } } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index 7eaad82ad..6716bbf26 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -67,7 +67,7 @@ public void shutdown() { shutdownNow(timeTimer); shutdownNow(platform.getInventoryTimer()); await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); - await(platform.getTimer(), 1, TimeUnit.SECONDS); + if (sharedRetirementFinished) await(platform.getTimer(), 1, TimeUnit.SECONDS); await(timeTimer, 1, TimeUnit.SECONDS); await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); clean(platform.afterExecutorShutdown()); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java new file mode 100644 index 000000000..64125590d --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/api/rewards/RewardOptionsTest.java @@ -0,0 +1,40 @@ +package com.bencodez.advancedcore.api.rewards; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RewardOptionsTest { + + @Test + void nestedDispatchPreservesCapturedLivePlayerState() { + RewardOptions nested = new RewardOptions().captureLivePlayerState(false, true) + .copyForNestedDispatch("parent/child:0"); + + assertTrue(nested.isLivePlayerStateSet()); + assertFalse(nested.isOnline()); + assertTrue(nested.isLivePlayerVanished()); + } + + @Test + void builtInNestedDispatchInheritsCapturedLivePlayerStateFromReplay() { + RewardOptions parent = new RewardOptions().captureLivePlayerState(false, true); + RewardOptions child = Reward.withReplayState(new RewardOptions(), Reward.replayStateFor(parent), + "parent", "child:0", "occurrence"); + + assertTrue(child.isLivePlayerStateSet()); + assertFalse(child.isOnline()); + assertTrue(child.isLivePlayerVanished()); + } + + @Test + void deferredChildHelperInheritsCapturedLivePlayerStateFromReplay() { + RewardOptions parent = new RewardOptions().captureLivePlayerState(false, true); + RewardOptions child = Reward.withReplayState(new RewardOptions(), Reward.replayStateFor(parent)); + + assertTrue(child.isLivePlayerStateSet()); + assertFalse(child.isOnline()); + assertTrue(child.isLivePlayerVanished()); + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index 405fab242..e52af2130 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -92,7 +92,7 @@ private ScheduledExecutorService executor(String name, List events) thro @Test void waitsForAsyncPreShutdownWorkBeforeRetiringExecutors() throws Exception { RuntimePlatform platform = platform(); - var events = new ArrayList(); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); ScheduledExecutorService timer = executor("timer", events); CompletableFuture retiring = new CompletableFuture<>(); when(platform.beforeExecutorShutdown()).thenReturn(List.of(new Cleanup("pre", () -> events.add("pre")))); @@ -125,6 +125,7 @@ private ScheduledExecutorService executor(String name, List events) thro assertDoesNotThrow(() -> new AdvancedCoreRuntime(platform).shutdown()); verify(timer, never()).shutdown(); verify(timer, never()).shutdownNow(); + verify(timer, never()).awaitTermination(anyLong(), any()); retiring.complete(null); verify(timer).shutdown(); } 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 d8683af4a..f50c72b63 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/rewards/RewardAsyncInjectionTest.java @@ -107,6 +107,10 @@ void setUp() { invocation.getArgument(1, Runnable.class).run(); return null; }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), any(Player.class)); when(plugin.getBukkitScheduler()).thenReturn(scheduler); AdvancedCorePlugin.setInstance(plugin); try { @@ -530,6 +534,57 @@ void replayExperienceRemainsPendingForNullAndDisconnectedPlayers() throws Except verify(player, never()).giveExp(5); } + @Test + void capturedReplayStateAvoidsWorkerThreadBukkitPlayerReads() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(false); + when(config.isTreatVanishAsOffline()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + RewardOptions options = new RewardOptions().setCheckTimed(false).setIgnoreRequirements(true); + options.captureLivePlayerState(false, true); + options.setTimedQueueReplay(true); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + assertThrows(java.util.concurrent.CompletionException.class, + () -> reward.giveRewardAsync(user, options).toCompletableFuture().join()); + } + + verify(user, never()).isOnline(); + verify(user, never()).isVanished(); + } + + @Test + void rewardPreparationRunsOnPlayerOwnerScheduler() { + Player player = mock(Player.class); + AtomicReference playerAccessThread = new AtomicReference<>(); + when(user.getPlayer()).thenAnswer(invocation -> { + playerAccessThread.set(Thread.currentThread()); + return player; + }); + when(player.getDisplayName()).thenReturn("Display"); + when(user.getPlayerName()).thenReturn("Queued"); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + doAnswer(invocation -> { + Thread ownerThread = new Thread(invocation.getArgument(1, Runnable.class), "reward-owner-thread"); + ownerThread.start(); + ownerThread.join(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class), eq(player)); + + reward.giveRewardUserAsync(user, new HashMap<>(), new RewardOptions()).toCompletableFuture().join(); + + assertNotNull(playerAccessThread.get()); + assertEquals("reward-owner-thread", playerAccessThread.get().getName()); + } + @Test void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); @@ -672,6 +727,72 @@ void directAsyncDispatchLeavesBukkitPrimaryThreadBeforeFiringEvent() { } } + @Test + void asyncRewardRequirementsRunOnPlayerOwnerScheduler() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(user.isOnline()).thenReturn(true); + AtomicReference requirementThread = new AtomicReference<>(); + handler.getInjectedRequirements().add(new RequirementInject("OwnerThread") { + @Override + public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, RewardOptions ignoredOptions) { + requirementThread.set(Thread.currentThread().getName()); + return false; + } + }); + ArgumentCaptor asyncTask = ArgumentCaptor.forClass(Runnable.class); + doAnswer(invocation -> { + Thread ownerThread = new Thread(invocation.getArgument(1, Runnable.class), "player-owner-thread"); + ownerThread.start(); + ownerThread.join(); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + CompletionStage result = reward.giveRewardAsync(user, new RewardOptions().setCheckTimed(false)); + + verify(scheduler).runTaskAsynchronously(eq(plugin), asyncTask.capture()); + asyncTask.getValue().run(); + result.toCompletableFuture().join(); + } + + assertEquals("player-owner-thread", requirementThread.get()); + } + + @Test + void terminalRequirementDenialDoesNotEnterPauseDeferral() { + AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); + when(config.isProcessRewards()).thenReturn(true); + when(config.isPauseRewards()).thenReturn(true); + when(config.getFormatRewardTimeFormat()).thenReturn("yyyy-MM-dd"); + when(plugin.getOptions()).thenReturn(config); + when(user.getPlayer()).thenReturn(mock(Player.class)); + when(user.isOnline()).thenReturn(true); + handler.getInjectedRequirements().add(new RequirementInject("Denied") { + @Override + public boolean onRequirementRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, RewardOptions ignoredOptions) { + return false; + } + }); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + reward.giveRewardAsync(user, new RewardOptions().setCheckTimed(false)).toCompletableFuture().join(); + } + + verify(user, never()).addOfflineRewards(any(), any(), any()); + } + @Test void timedOutPrimaryThreadHandoffCannotRunLateRewardSideEffects() throws Exception { Reward shortTimeoutReward = new Reward("AsyncReward", data) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/AdvancedCoreUserTest.java index c3d5ba77d..6235eb0c0 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 @@ -86,15 +86,33 @@ public void setUp() { user.setData(data); // Inject the mocked UserData object } + @Test + void updateNameDoesNotPersistABlankResolvedName() { + AdvancedCoreUser unnamed = new AdvancedCoreUser(plugin, UUID.randomUUID(), null); + unnamed.setData(data); + when(data.hasData()).thenReturn(true); + when(data.getString("PlayerName", UserDataFetchMode.TEMP_ONLY)).thenReturn(""); + when(data.getString("PlayerName", UserDataFetchMode.DEFAULT)).thenReturn("StoredName"); + + unnamed.updateName(false); + + verify(data, never()).getString("PlayerName", UserDataFetchMode.DEFAULT); + verify(data, never()).setString(eq("PlayerName"), any(String.class), eq(true)); + } + @Test void replayEntryPointsDeferCompleteSharedStorageWork() { + AdvancedCoreUser replayUser = org.mockito.Mockito.spy(user); + org.mockito.Mockito.doReturn(true).when(replayUser).isOnline(); + when(dataManager.mustDeferSharedStorageAccess()).thenReturn(true); when(dataManager.deferSharedStorageWork(any(Runnable.class))).thenReturn(true); - user.checkOfflineRewards(); - user.checkDelayedTimedRewards(); - user.forceRunOfflineRewards(); + replayUser.checkOfflineRewards(); + replayUser.checkDelayedTimedRewards(); + replayUser.forceRunOfflineRewards(); verify(dataManager, org.mockito.Mockito.times(3)).deferSharedStorageWork(any(Runnable.class)); + verify(replayUser, org.mockito.Mockito.times(3)).isOnline(); verify(data, never()).getStringList(any(String.class), any(UserDataFetchMode.class)); } From 4d75c4da06042cbd6b95c38259604a547d22946f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:43:22 -0600 Subject: [PATCH 27/34] Harden cache reads and shutdown ownership --- .../advancedcore/api/user/UserData.java | 36 ++++---- .../api/user/usercache/UserDataCache.java | 5 ++ .../bukkit/runtime/BukkitRuntimePlatform.java | 17 ++-- .../core/runtime/AdvancedCoreRuntime.java | 83 ++++++++++++------- .../tests/lifecycle/CoreRuntimeTest.java | 40 +++++++++ .../user/UserDataCacheSchedulingTest.java | 7 ++ .../advancedcore/tests/user/UserDataTest.java | 40 +++++++++ 7 files changed, 180 insertions(+), 48 deletions(-) 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 c978df3af..23ef025de 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 @@ -158,9 +158,11 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo } } - // 2) UserDataCache - if (mode.allowUserCache()) { - UserDataCache cache = user.getCache(); + // 2) UserDataCache + UserDataCache sharedReadCache = null; + if (mode.allowUserCache()) { + UserDataCache cache = user.getCache(); + sharedReadCache = cache; if (cache != null) { // preserve previous behavior user.cacheIfNeeded(); @@ -195,7 +197,7 @@ public int getInt(UserStorage storage, String key, int def, UserDataFetchMode mo // 3) Storage lookup if (mustDeferSharedStorageAccess()) { - rejectUnavailableFreshRead(mode); + rejectUnavailableFreshRead(mode, sharedReadCache); return def; } return sqlData.getInt(storage, key, def); @@ -275,9 +277,11 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) } } - // 2) UserDataCache - if (mode.allowUserCache()) { - UserDataCache cache = user.getCache(); + // 2) UserDataCache + UserDataCache sharedReadCache = null; + if (mode.allowUserCache()) { + UserDataCache cache = user.getCache(); + sharedReadCache = cache; if (cache != null) { if (cache.isCached(key)) { DataValue cv = cache.getCache().get(key); @@ -302,17 +306,16 @@ public String getString(UserStorage storage, String key, UserDataFetchMode mode) // 3) Storage lookup if (mustDeferSharedStorageAccess()) { - rejectUnavailableFreshRead(mode); + rejectUnavailableFreshRead(mode, sharedReadCache); return ""; } return sqlData.getString(storage, key); } - private void rejectUnavailableFreshRead(UserDataFetchMode mode) { - if (mode == UserDataFetchMode.NO_CACHE) { - throw new IllegalStateException( - "A fresh shared user-data read cannot run on the primary thread; defer it to the user-data worker"); - } + private void rejectUnavailableFreshRead(UserDataFetchMode mode, UserDataCache cache) { + if (mode.allowUserCache() && cache != null && cache.hasPublishedStorageSnapshot()) return; + throw new IllegalStateException( + "Shared user data is still loading; defer this read until cache population completes"); } private boolean mustDeferSharedStorageAccess() { @@ -635,7 +638,12 @@ private UserDataCache primaryThreadSharedCache(UserStorage storage) { if (manager == null || !manager.usesSharedSqlStorage(storage) || !manager.mustDeferSharedStorageAccess()) { return null; } - return user.getCache(); + UserDataCache cache = user.getCache(); + if (!cache.hasPublishedStorageSnapshot()) { + throw new IllegalStateException( + "Shared user data is still loading; defer this read until cache population completes"); + } + return cache; } public void setStringList(final String key, final ArrayList value) { 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 d2b32ae44..5f8735a0c 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 @@ -26,6 +26,7 @@ public class UserDataCache { private long snapshotVersion; private long replacementVersion; private boolean storedDataPresent; + private boolean storageSnapshotPublished; private final HashMap changedAt = new HashMap<>(); private final HashMap persistedAt = new HashMap<>(); private final HashMap inFlightValues = new HashMap<>(); @@ -271,6 +272,7 @@ private void dumpNow() { public AdvancedCoreUser getUser() { return manager.getPlugin().getUserManager().getUser(uuid, false); } public synchronized boolean hasCache() { return cache != null && !cache.isEmpty(); } public synchronized boolean hasStoredData() { return storedDataPresent; } + public synchronized boolean hasPublishedStorageSnapshot() { return storageSnapshotPublished; } public synchronized HashMap snapshot() { return cache == null ? new HashMap<>() : new HashMap<>(cache); } @@ -477,6 +479,7 @@ private synchronized void onScheduledFlushComplete() { public synchronized void updateCache(HashMap tempCache) { cache = tempCache == null ? new HashMap<>() : new HashMap<>(tempCache); + storageSnapshotPublished = true; recordSnapshotReplacement(); } @@ -486,6 +489,7 @@ public synchronized void updateCachePreservingPending(HashMap refreshed.putAll(inFlightValues); if (cachedChanges != null) for (UserDataChange change : cachedChanges) refreshed.put(change.getKey(), change.toUserDataValue()); cache = refreshed; + storageSnapshotPublished = true; recordSnapshotReplacement(); } @@ -506,6 +510,7 @@ private synchronized HashMap updateSharedSnapshot(HashMap { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); persistedAt.forEach((key, version) -> { if (version >= expectedVersion && cache.containsKey(key)) merged.put(key, cache.get(key)); }); cache = merged; + storageSnapshotPublished = true; recordSnapshotReplacement(); persistedAt.clear(); return new HashMap<>(cache); 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 7ca557d05..3233018bd 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 @@ -85,16 +85,21 @@ public BukkitRuntimePlatform(AdvancedCorePlugin plugin) { plugin.debug(failure); } - private void closeUserStorageAfterSharedRetirement() { + private void closeUserStorageAfterSharedRetirement() { if (!plugin.isLoadUserData()) { userStorageRetirement = CompletableFuture.completedFuture(null); return; } - Runnable closeMysql = () -> { - if (plugin.getOptions() != null && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()) - && plugin.getMysql() != null) plugin.getMysql().close(); - }; - UserManager users = plugin.getLoadedUserManager(); + UserManager users = plugin.getLoadedUserManager(); + var mysql = plugin.getMysql(); + boolean ownsMysql; + if (users != null && users.getDataManager().hasSharedSqlBackend()) { + ownsMysql = users.getDataManager().usesSharedSqlStorage(UserStorage.MYSQL); + } else { + ownsMysql = plugin.getOptions() != null + && UserStorage.MYSQL.equals(plugin.getOptions().getStorageType()); + } + Runnable closeMysql = () -> { if (ownsMysql && mysql != null) mysql.close(); }; if (users == null) { closeMysql.run(); userStorageRetirement = CompletableFuture.completedFuture(null); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index 6716bbf26..81b40f4b3 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -21,6 +21,8 @@ public final class AdvancedCoreRuntime { private static final long PRE_SHUTDOWN_WAIT_SECONDS = 5; private final RuntimePlatform platform; + private enum CleanupState { SUCCESS, FAILURE, DEFERRED } + private record ExecutorGrace(ScheduledExecutorService timeTimer) { } public AdvancedCoreRuntime(RuntimePlatform platform) { this.platform = Objects.requireNonNull(platform, "platform"); @@ -45,30 +47,44 @@ public static ExecutorGroup createExecutors() { public void shutdown() { clean(platform.beforeExecutorShutdown()); - boolean sharedRetirementFinished = awaitCleanup(platform.beforeExecutorShutdownCompletion(), "pre-executor shutdown"); + CompletionStage retirement = platform.beforeExecutorShutdownCompletion(); + CleanupState retirementState = awaitCleanup(retirement, "pre-executor shutdown"); + ExecutorGrace grace = beginExecutorGrace(retirementState == CleanupState.SUCCESS); + if (retirementState == CleanupState.DEFERRED) { + finishDeferredCleanup(retirement, "pre-executor shutdown", grace); + return; + } + boolean timerForced = retirementState == CleanupState.FAILURE; + if (timerForced) shutdownNow(platform.getTimer()); + finishAfterExecutorGrace(grace, timerForced); + } + private ExecutorGrace beginExecutorGrace(boolean stopStorageTimer) { // Resolve the time-checker timer once, after the pre-shutdown actions, // just as the old lifecycle did. Other getters retain their lookup order. ScheduledExecutorService timeTimer = platform.getTimeTimer(); shutdown(platform.getLoginTimer()); - if (sharedRetirementFinished) shutdown(platform.getTimer()); + if (stopStorageTimer) shutdown(platform.getTimer()); shutdown(timeTimer); shutdown(platform.getInventoryTimer()); platform.info("Allowing background tasks to finish before shutdown"); await(platform.getLoginTimer(), 2, TimeUnit.SECONDS); - if (sharedRetirementFinished) await(platform.getTimer(), 2, TimeUnit.SECONDS); + if (stopStorageTimer) await(platform.getTimer(), 2, TimeUnit.SECONDS); await(timeTimer, 2, TimeUnit.SECONDS); await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); + return new ExecutorGrace(timeTimer); + } + private void finishAfterExecutorGrace(ExecutorGrace grace, boolean storageTimerAlreadyForced) { clean(platform.afterExecutorGrace()); shutdownNow(platform.getLoginTimer()); - if (sharedRetirementFinished) shutdownNow(platform.getTimer()); - shutdownNow(timeTimer); + if (!storageTimerAlreadyForced) shutdownNow(platform.getTimer()); + shutdownNow(grace.timeTimer()); shutdownNow(platform.getInventoryTimer()); await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); - if (sharedRetirementFinished) await(platform.getTimer(), 1, TimeUnit.SECONDS); - await(timeTimer, 1, TimeUnit.SECONDS); + await(platform.getTimer(), 1, TimeUnit.SECONDS); + await(grace.timeTimer(), 1, TimeUnit.SECONDS); await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); clean(platform.afterExecutorShutdown()); } @@ -89,52 +105,63 @@ private void clean(List actions) { * and native provider until it has either flushed successfully or reported its * own failure/retry outcome. */ - private boolean awaitCleanup(CompletionStage completion, String component) { - if (completion == null) return true; + private CleanupState awaitCleanup(CompletionStage completion, String component) { + if (completion == null) return CleanupState.SUCCESS; var future = completion.toCompletableFuture(); if (!platform.canBlockForPreExecutorShutdown() && !future.isDone()) { - finishDeferredCleanup(completion, component); - return false; + return CleanupState.DEFERRED; } try { if (future.isDone()) future.join(); else future.get(PRE_SHUTDOWN_WAIT_SECONDS, TimeUnit.SECONDS); - return true; + return CleanupState.SUCCESS; } catch (TimeoutException timeout) { platform.cleanupFailed(component, timeout); - finishDeferredCleanup(completion, component); - return false; + return CleanupState.DEFERRED; } catch (CompletionException failure) { Throwable cause = failure.getCause() == null ? failure : failure.getCause(); platform.cleanupFailed(component, cause); - shutdownNow(platform.getTimer()); - return false; + return CleanupState.FAILURE; } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); platform.cleanupFailed(component, interrupted); - finishDeferredCleanup(completion, component); - return false; + return future.isDone() ? CleanupState.FAILURE : CleanupState.DEFERRED; } catch (Exception failure) { Throwable cause = failure.getCause() == null ? failure : failure.getCause(); platform.cleanupFailed(component, cause); - if (future.isDone()) shutdownNow(platform.getTimer()); - else finishDeferredCleanup(completion, component); - return false; + if (future.isDone()) { + return CleanupState.FAILURE; + } + return CleanupState.DEFERRED; } } - /** Retire the platform timer only after deferred storage work has actually completed. */ - private void finishDeferredCleanup(CompletionStage completion, String component) { + /** Finish every teardown phase only after deferred storage work has completed. */ + private void finishDeferredCleanup(CompletionStage completion, String component, ExecutorGrace grace) { ScheduledExecutorService timer = platform.getTimer(); completion.whenComplete((ignored, failure) -> { + boolean timerForced = failure != null; if (failure == null) { shutdown(timer); - return; + } else { + Throwable cause = failure instanceof CompletionException && failure.getCause() != null + ? failure.getCause() : failure; + platform.cleanupFailed(component, cause); + shutdownNow(timer); + } + Runnable continuation = () -> { + await(timer, failure == null ? 2 : 1, TimeUnit.SECONDS); + finishAfterExecutorGrace(grace, timerForced); + }; + // Completion normally runs on the storage timer itself. Let that callback + // return before awaiting the timer, otherwise it waits for its own task. + Thread shutdownThread = new Thread(continuation, "AdvancedCore-Shutdown"); + shutdownThread.setDaemon(true); + try { shutdownThread.start(); } + catch (RuntimeException | Error startFailure) { + platform.cleanupFailed("deferred shutdown continuation", startFailure); + continuation.run(); } - Throwable cause = failure instanceof CompletionException && failure.getCause() != null - ? failure.getCause() : failure; - platform.cleanupFailed(component, cause); - shutdownNow(timer); }); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index e52af2130..291206ac8 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -118,16 +118,25 @@ private ScheduledExecutorService executor(String name, List events) thro RuntimePlatform platform = platform(); ScheduledExecutorService timer = mock(ScheduledExecutorService.class); CompletableFuture retiring = new CompletableFuture<>(); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); when(platform.getTimer()).thenReturn(timer); + when(platform.afterExecutorGrace()).thenReturn(List.of( + new Cleanup("reward", () -> events.add("reward")))); + when(platform.afterExecutorShutdown()).thenReturn(List.of( + new Cleanup("unload", () -> events.add("unload")))); assertDoesNotThrow(() -> new AdvancedCoreRuntime(platform).shutdown()); verify(timer, never()).shutdown(); verify(timer, never()).shutdownNow(); verify(timer, never()).awaitTermination(anyLong(), any()); + assertTrue(events.isEmpty(), "unload hooks must wait for storage retirement"); retiring.complete(null); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (!events.contains("unload") && System.nanoTime() < deadline) Thread.yield(); verify(timer).shutdown(); + assertEquals(List.of("reward", "unload"), events); } @Test void deferredRetirementFailureTerminatesItsWorkerAfterReportingTheFailure() { @@ -143,6 +152,10 @@ private ScheduledExecutorService executor(String name, List events) thro verify(timer, never()).shutdownNow(); IllegalStateException failure = new IllegalStateException("write failed"); retiring.completeExceptionally(failure); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (mockingDetails(timer).getInvocations().stream() + .noneMatch(invocation -> invocation.getMethod().getName().equals("shutdownNow")) + && System.nanoTime() < deadline) Thread.yield(); verify(platform).cleanupFailed("pre-executor shutdown", failure); verify(timer).shutdownNow(); } @@ -247,4 +260,31 @@ private ScheduledExecutorService executor(String name, List events) thro retired.complete(null); verify(mysql).close(); } + + @Test void bukkitAdapterClosesTheCapturedMysqlOwnerAfterConfigurationChanges() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL mysql = mock(MySQL.class); + UserManager users = mock(UserManager.class); + UserDataManager dataManager = mock(UserDataManager.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(plugin.isLoadUserData()).thenReturn(true); + when(plugin.getOptions()).thenReturn(options); + when(options.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getMysql()).thenReturn(mysql); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(dataManager); + when(dataManager.hasSharedSqlBackend()).thenReturn(true); + when(dataManager.usesSharedSqlStorage(UserStorage.MYSQL)).thenReturn(true); + when(dataManager.closeSharedRuntimeAsyncCompletion(any(Runnable.class))).thenAnswer(call -> { + call.getArgument(0, Runnable.class).run(); + return CompletableFuture.completedFuture(null); + }); + + BukkitRuntimePlatform platform = new BukkitRuntimePlatform(plugin); + platform.beforeExecutorShutdown().stream() + .filter(cleanup -> cleanup.name().equals("user storage")) + .findFirst().orElseThrow().action().run(); + + verify(mysql).close(); + } } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java index 838513c60..5d689e390 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataCacheSchedulingTest.java @@ -194,6 +194,13 @@ public void updateCacheDefensivelyCopiesInput() { values.clear(); assertTrue(cache.isCached("PlayerName")); + assertTrue(cache.hasPublishedStorageSnapshot()); + } + + @Test + public void emptyPlaceholderIsNotACompletedStorageSnapshot() { + UserDataCache cache = new UserDataCache(mock(UserDataManager.class), UUID.randomUUID()); + assertFalse(cache.hasPublishedStorageSnapshot()); } @Test diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java index 432011368..058806321 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataTest.java @@ -7,11 +7,18 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; +import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.data.DataValue; @@ -81,4 +88,37 @@ public void testConvert_mapsColumnNameToValue() { assertSame(v1, out.get("a")); assertSame(v2, out.get("b")); } + + @Test + public void incompleteSharedCacheNeverMasqueradesAsPersistedDefaults() { + UUID uuid = UUID.randomUUID(); + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + UserDataCache placeholder = mock(UserDataCache.class); + when(user.getPlugin()).thenReturn(plugin); + when(user.getUUID()).thenReturn(uuid.toString()); + when(user.getCache()).thenReturn(placeholder); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(users.getDataManager()).thenReturn(manager); + when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + when(manager.mustDeferSharedStorageAccess()).thenReturn(true); + when(placeholder.hasPublishedStorageSnapshot()).thenReturn(false); + + UserData data = new UserData(user); + assertThrows(IllegalStateException.class, + () -> data.getInt("Points", 17, UserDataFetchMode.DEFAULT)); + assertThrows(IllegalStateException.class, + () -> data.getString("PlayerName", UserDataFetchMode.DEFAULT)); + assertThrows(IllegalStateException.class, data::getValues); + assertThrows(IllegalStateException.class, data::hasData); + + when(placeholder.hasPublishedStorageSnapshot()).thenReturn(true); + assertEquals(17, data.getInt("Points", 17, UserDataFetchMode.DEFAULT)); + assertEquals("", data.getString("PlayerName", UserDataFetchMode.DEFAULT)); + assertThrows(IllegalStateException.class, + () -> data.getInt("Points", 17, UserDataFetchMode.NO_CACHE)); + } } From fc2edf8dd93c29dbb42a892cfce3d82e58a4b6a1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 20:52:53 -0600 Subject: [PATCH 28/34] Preserve shared user state across async boundaries --- .../advancedcore/api/user/UserData.java | 57 +++++++++++-------- .../api/user/usercache/UserDataCache.java | 5 ++ .../api/user/usercache/UserDataManager.java | 19 ++++++- .../advancedcore/command/gui/UserGUI.java | 5 +- .../SharedCacheBindingRegressionTest.java | 48 ++++++++++++---- .../SharedCacheCleanupPrimaryThreadTest.java | 25 ++++++-- .../advancedcore/tests/user/UserDataTest.java | 1 + 7 files changed, 119 insertions(+), 41 deletions(-) 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 23ef025de..f5760e636 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 @@ -86,12 +86,12 @@ public int getInt(String key, UserDataFetchMode mode) { return getInt(key, 0, mode); } - public int getInt(String key, int def) { - return getInt(user.getPlugin().getStorageType(), key, def, user.getUserDataFetchMode()); - } + public int getInt(String key, int def) { + return getInt(effectiveStorageType(), key, def, user.getUserDataFetchMode()); + } public int getInt(String key, int def, UserDataFetchMode mode) { - return getInt(user.getPlugin().getStorageType(), key, def, mode); + return getInt(effectiveStorageType(), key, def, mode); } /** @@ -115,7 +115,7 @@ public int getInt(String key, boolean useCache, boolean waitForCache) { */ @Deprecated public int getInt(String key, int def, boolean waitForCache) { - return getInt(user.getPlugin().getStorageType(), key, def, UserDataFetchMode.fromBooleans(true, waitForCache)); + return getInt(effectiveStorageType(), key, def, UserDataFetchMode.fromBooleans(true, waitForCache)); } /** @@ -123,7 +123,7 @@ public int getInt(String key, int def, boolean waitForCache) { */ @Deprecated public int getInt(String key, int def, boolean useCache, boolean waitForCache) { - return getInt(user.getPlugin().getStorageType(), key, def, + return getInt(effectiveStorageType(), key, def, UserDataFetchMode.fromBooleans(useCache, waitForCache)); } @@ -212,7 +212,7 @@ public int getInt(UserStorage storage, String key, int def, boolean useCache, bo } public ArrayList getKeys() { - return getKeys(user.getPlugin().getStorageType()); + return getKeys(effectiveStorageType()); } public ArrayList getKeys(UserStorage storage) { @@ -227,7 +227,7 @@ public ArrayList getKeys(UserStorage storage) { */ @Deprecated public ArrayList getKeys(boolean waitForCache) { - return getKeys(user.getPlugin().getStorageType()); + return getKeys(effectiveStorageType()); } /** @@ -251,7 +251,7 @@ public String getString(String key) { } public String getString(String key, UserDataFetchMode mode) { - return getString(user.getPlugin().getStorageType(), key, mode); + return getString(effectiveStorageType(), key, mode); } public String getString(UserStorage storage, String key, UserDataFetchMode mode) { @@ -376,7 +376,7 @@ public String getValue(String key) { } public HashMap getValues() { - return getValues(user.getPlugin().getStorageType()); + return getValues(effectiveStorageType()); } public HashMap getValues(UserStorage storage) { @@ -387,14 +387,14 @@ public HashMap getValues(UserStorage storage) { } public boolean hasData() { - UserStorage storage = user.getPlugin().getStorageType(); + UserStorage storage = effectiveStorageType(); ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); UserDataCache sharedCache = primaryThreadSharedCache(storage); return sharedCache == null ? sqlData.hasData(storage) : sharedCache.hasStoredData(); } public void remove() { - UserStorage storage = user.getPlugin().getStorageType(); + UserStorage storage = effectiveStorageType(); UserDataManager manager = sharedDataManager(); if (manager != null && manager.hasSharedRuntime()) { // The runtime serializes flush, delete, and cache retirement under its @@ -421,11 +421,11 @@ public void setInt(final String key, final int value) { } public void setInt(final String key, final int value, boolean queue) { - setInt(user.getPlugin().getStorageType(), key, value, queue); + setInt(effectiveStorageType(), key, value, queue); } public void setInt(final String key, final int value, boolean queue, boolean async) { - setInt(user.getPlugin().getStorageType(), key, value, queue, async); + setInt(effectiveStorageType(), key, value, queue, async); } public void setInt(UserStorage storage, final String key, final int value, boolean queue) { @@ -491,11 +491,11 @@ public void setString(final String key, final String value) { } public void setString(final String key, final String value, boolean queue) { - setString(user.getPlugin().getStorageType(), key, value, queue); + setString(effectiveStorageType(), key, value, queue); } public void setString(final String key, final String value, boolean queue, boolean async) { - setString(user.getPlugin().getStorageType(), key, value, queue, async); + setString(effectiveStorageType(), key, value, queue, async); } public void setString(UserStorage storage, final String key, final String value, boolean queue) { @@ -564,13 +564,16 @@ private boolean queueSharedMutation(UserStorage storage, UserDataChange change, return false; } java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); - Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, - () -> applySharedMutation(manager, user.getCache(), change, queue, false)); if (manager.mustDeferSharedStorageAccess()) { - // Create/schedule the placeholder before the mutation task so the manager's - // single worker preserves population-before-write ordering. - user.getCache(); - return manager.deferSharedStorageWork(mutation); + // Publish read-after-write state synchronously, then defer only persistence. + // Population was queued by getCache() first and its version fences preserve + // this pending value when the older storage snapshot arrives. + UserDataCache cache = user.getCache(); + cache.addChangeBeforeDeferredSharedFlush(change); + if (queue) manager.dispatchSharedStorageNotification(() -> + user.getPlugin().getUserManager().onChange(user, change.getKey())); + else manager.deferSharedStorageWork(() -> cache.processChangesImmediately(false)); + return true; } manager.withSharedSqlStorage(uuid, storage, () -> applySharedMutation(manager, user.getCache(), change, queue, async)); @@ -633,6 +636,12 @@ private UserDataManager sharedDataManager() { return userManager == null ? null : userManager.getDataManager(); } + private UserStorage effectiveStorageType() { + UserStorage configured = user.getPlugin().getStorageType(); + UserDataManager manager = sharedDataManager(); + return manager == null ? configured : manager.effectiveStorageType(configured); + } + private UserDataCache primaryThreadSharedCache(UserStorage storage) { UserDataManager manager = sharedDataManager(); if (manager == null || !manager.usesSharedSqlStorage(storage) || !manager.mustDeferSharedStorageAccess()) { @@ -662,13 +671,13 @@ public void setStringList(final String key, final ArrayList value, boole } public void setValues(HashMap values) { - setValues(user.getPlugin().getStorageType(), values); + setValues(effectiveStorageType(), values); } public void setValues(String key, DataValue value) { HashMap values = new HashMap<>(); values.put(key, value); - setValues(user.getPlugin().getStorageType(), values); + setValues(effectiveStorageType(), values); } public void setValues(UserStorage storage, HashMap values) { 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 5f8735a0c..4acd9bbae 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 @@ -60,6 +60,11 @@ public void addChange(UserDataChange change, boolean queue) { gate.accept(() -> addChangeInternal(change, queue)); } + /** Publish a primary-thread value before only its shared-store flush is deferred. */ + public synchronized void addChangeBeforeDeferredSharedFlush(UserDataChange change) { + addChangeInternal(change, true); + } + private synchronized void addChangeInternal(UserDataChange change, boolean queue) { if (change != null && sharedStorageWriter != null && (cache == null || cachedChanges == null)) { throw new IllegalStateException("Shared user cache is retired"); 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 822f19504..7579ac279 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 @@ -186,6 +186,12 @@ public final boolean usesSharedSqlStorage(com.bencodez.advancedcore.api.user.Use return route != null && route.backend().storageType() == storage; } + /** Prefer the immutable active shared route over a newly reloaded option. */ + public final UserStorage effectiveStorageType(UserStorage configured) { + SharedSqlRoute route = sharedSqlRoute; + return route == null ? Objects.requireNonNull(configured, "configured") : route.backend().storageType(); + } + /** * Run an explicit storage-maintenance operation behind the shared runtime's * write barrier. Calls made by the operation may target a non-current store @@ -716,6 +722,12 @@ public final boolean deferSharedStorageWork(Runnable task) { */ public final boolean deferSharedStorageResult(Supplier storageWork, Consumer success, Consumer failure) { + return deferSharedStorageResult(storageWork, success, failure, null); + } + + /** Return player-facing completions through that entity's owning scheduler. */ + public final boolean deferSharedStorageResult(Supplier storageWork, Consumer success, + Consumer failure, org.bukkit.entity.Entity callbackOwner) { Objects.requireNonNull(storageWork, "storageWork"); Objects.requireNonNull(success, "success"); Objects.requireNonNull(failure, "failure"); @@ -734,7 +746,7 @@ public final boolean deferSharedStorageResult(Supplier storageWork, Consu dispatchSharedStorageNotification(() -> { if (completedFailure == null) success.accept(completed); else failure.accept(completedFailure); - }); + }, callbackOwner); }); } catch (RejectedExecutionException rejected) { reportDeferredStorageFailure(rejected); @@ -745,8 +757,13 @@ public final boolean deferSharedStorageResult(Supplier storageWork, Consu /** Return a deferred storage completion to Bukkit/Folia's safe scheduler. */ public final void dispatchSharedStorageNotification(Runnable notification) { + dispatchSharedStorageNotification(notification, null); + } + + private void dispatchSharedStorageNotification(Runnable notification, org.bukkit.entity.Entity callbackOwner) { Objects.requireNonNull(notification, "notification"); if (Bukkit.getServer() == null || Bukkit.isPrimaryThread()) notification.run(); + else if (callbackOwner != null) plugin.getBukkitScheduler().runTask(plugin, notification, callbackOwner); else plugin.getBukkitScheduler().runTask(plugin, notification); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java index 1bbda7601..e0266d04e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/gui/UserGUI.java @@ -118,7 +118,7 @@ public void onClick(ClickEvent clickEvent) { final AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, values -> openEditData(player, playerName, user, values), - failure -> player.sendMessage("Unable to read user data; check the server log."))) return; + failure -> player.sendMessage("Unable to read user data; check the server log."), player)) return; openEditData(player, playerName, user, user.getData().getValues()); } }); @@ -130,7 +130,8 @@ public void onClick(ClickEvent clickEvent) { AdvancedCoreUser user = plugin.getUserManager().getUser(playerName); if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, values -> sendUserData(user, values), - failure -> clickEvent.getPlayer().sendMessage("Unable to read user data; check the server log."))) return; + failure -> clickEvent.getPlayer().sendMessage("Unable to read user data; check the server log."), + clickEvent.getPlayer())) return; sendUserData(user, user.getData().getValues()); } }); 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 1ab67e2b0..02c42f5ce 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 @@ -387,10 +387,17 @@ private void cacheMiss(UserDataFetchMode mode) { bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, true, false); - assertDoesNotThrow(() -> new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, false, false)); - assertFalse(fixture.first.rows.containsKey(fixture.uuid)); - fixture.tasks.get(fixture.tasks.size() - 1).run(); - assertEquals(9, fixture.first.points(fixture.uuid)); + UserData data = new UserData(user); + assertDoesNotThrow(() -> data.setInt(UserStorage.MYSQL, "Points", 9, false, false)); + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + assertEquals(9, cache.snapshot().get("Points").getInt(), + "the setter must publish read-after-write state before returning"); + data.setInt(UserStorage.MYSQL, "Points", + data.getInt(UserStorage.MYSQL, "Points", 0, UserDataFetchMode.DEFAULT) + 1, false, false); + assertEquals(10, cache.snapshot().get("Points").getInt(), + "same-tick read-modify-write must observe the preceding setter"); + fixture.tasks.get(fixture.tasks.size() - 1).run(); + assertEquals(10, fixture.first.points(fixture.uuid)); var callback = org.mockito.ArgumentCaptor.forClass(Runnable.class); verify(fixture.plugin.getBukkitScheduler()).runTask(eq(fixture.plugin), callback.capture()); verify(userManager, never()).onChange(any(), any(String[].class)); @@ -440,22 +447,43 @@ private void cacheMiss(UserDataFetchMode mode) { assertDoesNotThrow(() -> new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, true, false)); assertFalse(fixture.first.rows.containsKey(fixture.uuid), "the primary thread must not write SQL"); - assertEquals(2, fixture.tasks.size(), "population must be queued before mutation admission"); - assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).getCache().containsKey("Points"), - "the primary thread must not enter the shared cache mutation gate"); + assertEquals(2, fixture.tasks.size(), "population must be queued before the delayed flush"); + assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), + "the primary-thread setter must be immediately visible"); fixture.tasks.get(0).run(); - assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).getCache().containsKey("Points")); + assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), + "the older read publication must retain the immediate mutation"); fixture.tasks.get(1).run(); assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), "read publication must retain the queued mutation"); - assertEquals(3, fixture.tasks.size()); - fixture.tasks.get(2).run(); assertEquals(9, fixture.first.points(fixture.uuid)); runtime.close(); } } + @Test void implicitFacadeUsesTheRuntimeStorageAfterOptionsReload() throws Exception { + try (Fixture fixture = new Fixture()) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.getCache()).thenReturn(cache); + + UserData data = new UserData(user); + data.setInt("Points", 14, false); + + assertEquals(UserStorage.MYSQL, + fixture.manager.effectiveStorageType(fixture.plugin.getStorageType())); + assertEquals(14, fixture.first.points(fixture.uuid)); + assertEquals(14, data.getInt("Points", UserDataFetchMode.DEFAULT)); + assertEquals(14, data.getValues().get("Points").getInt()); + runtime.close(); + } + } + @Test void workerMutationCannotBypassAPendingPrimaryThreadPopulation() throws Exception { try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { SharedUserDataRuntime runtime = fixture.runtime(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index c22842acf..d1868df43 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -3,12 +3,14 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -31,6 +33,7 @@ import org.bukkit.Bukkit; import org.bukkit.Server; +import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -143,12 +146,14 @@ void primaryThreadStorageResultIsReadOnTheWorkerAndDeliveredBackToBukkit() throw timer.set(manager, worker); SqlUserBackend backend = mock(SqlUserBackend.class); manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); - when(plugin.getBukkitScheduler()).thenReturn(mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class)); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + Player callbackOwner = mock(Player.class); AtomicBoolean read = new AtomicBoolean(); AtomicBoolean delivered = new AtomicBoolean(); try (var bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); - bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false, true, false); ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); assertTrue(manager.deferSharedStorageResult(() -> { read.set(true); @@ -156,16 +161,28 @@ void primaryThreadStorageResultIsReadOnTheWorkerAndDeliveredBackToBukkit() throw }, value -> { assertEquals("row", value); delivered.set(true); - }, failure -> { throw new AssertionError(failure); })); + }, failure -> { throw new AssertionError(failure); }, callbackOwner)); verify(worker).execute(task.capture()); assertFalse(read.get()); task.getValue().run(); assertTrue(read.get()); assertFalse(delivered.get()); ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); - verify(plugin.getBukkitScheduler()).runTask(eq(plugin), callback.capture()); + verify(scheduler).runTask(eq(plugin), callback.capture(), same(callbackOwner)); callback.getValue().run(); assertTrue(delivered.get()); + + AtomicBoolean failed = new AtomicBoolean(); + assertTrue(manager.deferSharedStorageResult(() -> { + throw new IllegalStateException("read failed"); + }, value -> fail("unexpected success"), failure -> failed.set(true), callbackOwner)); + ArgumentCaptor failedTask = ArgumentCaptor.forClass(Runnable.class); + verify(worker, times(2)).execute(failedTask.capture()); + failedTask.getAllValues().get(1).run(); + ArgumentCaptor failedCallback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler, times(2)).runTask(eq(plugin), failedCallback.capture(), same(callbackOwner)); + failedCallback.getAllValues().get(1).run(); + assertTrue(failed.get()); } manager.getTimer().shutdownNow(); } 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 058806321..b8b886496 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 @@ -104,6 +104,7 @@ public void incompleteSharedCacheNeverMasqueradesAsPersistedDefaults() { when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(users.getDataManager()).thenReturn(manager); when(manager.usesSharedSqlStorage(UserStorage.SQLITE)).thenReturn(true); + when(manager.effectiveStorageType(UserStorage.SQLITE)).thenReturn(UserStorage.SQLITE); when(manager.mustDeferSharedStorageAccess()).thenReturn(true); when(placeholder.hasPublishedStorageSnapshot()).thenReturn(false); From 279319855c127db03fd2b028f792a19e020392a7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:00:56 -0600 Subject: [PATCH 29/34] Snapshot deferred user data inputs --- .../advancedcore/api/user/UserData.java | 16 ++++++---- .../api/user/usercache/UserDataManager.java | 27 ++++++++++++----- .../SharedCacheBindingRegressionTest.java | 23 ++++++++++++++ .../SharedCacheCleanupPrimaryThreadTest.java | 30 +++++++++++++++++++ 4 files changed, 82 insertions(+), 14 deletions(-) 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 f5760e636..6ea78096d 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 @@ -597,18 +597,22 @@ private boolean queueSharedValues(UserStorage storage, HashMap changes = new ArrayList<>(); + for (java.util.Map.Entry entry : values.entrySet()) { + if (entry.getKey() == null || "uuid".equalsIgnoreCase(entry.getKey()) || entry.getValue() == null) continue; + changes.add(change(entry.getKey(), entry.getValue())); + } + if (changes.isEmpty()) return true; java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, () -> { UserDataCache cache = user.getCache(); - for (java.util.Map.Entry entry : values.entrySet()) { - if (entry.getKey() == null || "uuid".equalsIgnoreCase(entry.getKey()) || entry.getValue() == null) continue; - cache.addChange(change(entry.getKey(), entry.getValue()), true); - } + for (UserDataChange change : changes) cache.addChange(change, true); cache.processChangesImmediately(false); }); if (manager.mustDeferSharedStorageAccess()) { - user.getCache(); - return manager.deferSharedStorageWork(mutation); + UserDataCache cache = user.getCache(); + for (UserDataChange change : changes) cache.addChangeBeforeDeferredSharedFlush(change); + return manager.deferSharedStorageWork(() -> cache.processChangesImmediately(false)); } mutation.run(); return true; 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 7579ac279..d975ae01e 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 @@ -575,16 +575,27 @@ private boolean deferSharedCachePopulation(UUID uuid, boolean traceDevelopmentCa finally { cacheMapLifecycle.readLock().unlock(); } Throwable deferredFailure = failure; boolean current = finishSharedCachePopulation(population, deferredFailure == null); - if (!current || !populated[0]) return; + if (!current) { + if (deferredFailure != null) reportDeferredStorageFailure(deferredFailure); + return; + } + if (deferredFailure != null) { + reportDeferredStorageFailure(deferredFailure); + dispatchSharedStorageNotification(() -> { + try { notifyCacheChangesAfterFailure(refreshed, deferredFailure); } + catch (RuntimeException | Error notificationFailure) { + deferredFailure.addSuppressed(notificationFailure); + reportDeferredStorageFailure(deferredFailure); + } + }); + return; + } + if (!populated[0]) return; dispatchSharedStorageNotification(() -> { - try { - if (deferredFailure == null) notifyCacheChanges(refreshed); - else notifyCacheChangesAfterFailure(refreshed, deferredFailure); - } catch (RuntimeException | Error notificationFailure) { - if (deferredFailure != null) deferredFailure.addSuppressed(notificationFailure); - else reportDeferredStorageFailure(notificationFailure); + try { notifyCacheChanges(refreshed); } + catch (RuntimeException | Error notificationFailure) { + reportDeferredStorageFailure(notificationFailure); } - if (deferredFailure != null) reportDeferredStorageFailure(deferredFailure); }); }); } catch (RejectedExecutionException rejected) { 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 02c42f5ce..9377cd7a9 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 @@ -484,6 +484,29 @@ private void cacheMiss(UserDataFetchMode mode) { } } + @Test void primaryThreadBulkSetterSnapshotsAndPublishesBeforeReturning() throws Exception { + try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { + SharedUserDataRuntime runtime = fixture.runtime(); + UserDataCache cache = fixture.manager.getCache(fixture.uuid); + AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); + when(fixture.plugin.getUserManager().getDataManager()).thenReturn(fixture.manager); + when(user.getPlugin()).thenReturn(fixture.plugin); + when(user.getUUID()).thenReturn(fixture.uuid.toString()); + when(user.getCache()).thenReturn(cache); + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + HashMap submitted = new HashMap<>(Map.of("Points", new DataValueInt(18))); + + new UserData(user).setValues(submitted); + + assertEquals(18, cache.snapshot().get("Points").getInt()); + submitted.clear(); + fixture.tasks.get(fixture.tasks.size() - 1).run(); + assertEquals(18, fixture.first.points(fixture.uuid)); + runtime.close(); + } + } + @Test void workerMutationCannotBypassAPendingPrimaryThreadPopulation() throws Exception { try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { SharedUserDataRuntime runtime = fixture.runtime(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index d1868df43..ef0eecd39 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -12,6 +12,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.doCallRealMethod; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -220,6 +221,35 @@ void acceptedPrimaryThreadCleanupFailureIsLoggedAndRetained() throws Exception { } } + @Test + void failedPrimaryThreadPopulationIsRetainedBeforeNotificationDelivery() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("storage unavailable"); + when(plugin.getUserManager().getDataManager()).thenReturn(manager); + when(plugin.getUserManager().getUser(uuid, false).getUserData().getKeys()).thenThrow(failure); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); + + manager.cacheUser(uuid, null); + verify(worker).execute(task.capture()); + task.getValue().run(); + + assertSame(failure, manager.getLastDeferredStorageFailure()); + } + } + @Test void primaryThreadCachePopulationIsQueuedAndGetCacheReturnsAPopulationPlaceholder() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); From 1b3d0fa66fe61f5252b1665bd9d77ab648e87399 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:09:59 -0600 Subject: [PATCH 30/34] Fence synchronous cache publication --- .../advancedcore/api/user/UserData.java | 17 ++++-- .../api/user/usercache/UserDataCache.java | 18 +++++- .../user/runtime/BukkitUserCacheOwner.java | 18 ++++++ .../user/runtime/SharedUserDataRuntime.java | 51 +++++++++++----- .../core/user/runtime/UserCacheOwner.java | 8 +++ .../SharedCacheBindingRegressionTest.java | 16 ++--- .../SharedUserLifecycleRegressionTest.java | 60 ++++++++++++++++++- 7 files changed, 155 insertions(+), 33 deletions(-) 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 6ea78096d..7c56650b2 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 @@ -564,19 +564,22 @@ private boolean queueSharedMutation(UserStorage storage, UserDataChange change, return false; } java.util.UUID uuid = java.util.UUID.fromString(user.getUUID()); + Runnable mutation = () -> manager.withSharedSqlStorage(uuid, storage, + () -> applySharedMutation(manager, user.getCache(), change, queue, async)); if (manager.mustDeferSharedStorageAccess()) { // Publish read-after-write state synchronously, then defer only persistence. - // Population was queued by getCache() first and its version fences preserve - // this pending value when the older storage snapshot arrives. + // An unbound placeholder or retiring cache defers the entire mutation so it + // can join the correct lifecycle generation on the storage worker. UserDataCache cache = user.getCache(); - cache.addChangeBeforeDeferredSharedFlush(change); + if (!cache.tryAddChangeBeforeDeferredSharedFlush(change)) { + return manager.deferSharedStorageWork(mutation); + } if (queue) manager.dispatchSharedStorageNotification(() -> user.getPlugin().getUserManager().onChange(user, change.getKey())); else manager.deferSharedStorageWork(() -> cache.processChangesImmediately(false)); return true; } - manager.withSharedSqlStorage(uuid, storage, - () -> applySharedMutation(manager, user.getCache(), change, queue, async)); + mutation.run(); return true; } @@ -611,7 +614,9 @@ private boolean queueSharedValues(UserStorage storage, HashMap cache.processChangesImmediately(false)); } mutation.run(); 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 4acd9bbae..6b36c6639 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 @@ -60,9 +60,21 @@ public void addChange(UserDataChange change, boolean queue) { gate.accept(() -> addChangeInternal(change, queue)); } - /** Publish a primary-thread value before only its shared-store flush is deferred. */ - public synchronized void addChangeBeforeDeferredSharedFlush(UserDataChange change) { - addChangeInternal(change, true); + /** + * Publish into an already bound cache without waiting for its user gate. The + * cache monitor serializes this with retirement markers: a change accepted + * first is included in the following flush, while a transition that marks the + * cache first makes the caller defer the complete mutation instead. + */ + public synchronized boolean tryAddChangeBeforeDeferredSharedFlush(UserDataChange change) { + return tryAddChangesBeforeDeferredSharedFlush(java.util.List.of(change)); + } + + /** Atomically publish one complete caller batch against cache retirement. */ + public synchronized boolean tryAddChangesBeforeDeferredSharedFlush(Iterable changes) { + if (sharedFlushGate == null || removing || uuid == null || cache == null || cachedChanges == null) return false; + for (UserDataChange change : changes) addChangeInternal(change, true); + return true; } private synchronized void addChangeInternal(UserDataChange change, boolean queue) { 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 ccb169ab3..906a26802 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 @@ -255,6 +255,24 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp @Override public Set cachedUsers() { return new HashSet<>(manager.getUserDataCache().keySet()); } + @Override public void beginRemoval(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) cache.beginRemoval(); + } + + @Override public void cancelRemoval(UUID uuid) { + UserDataCache cache = manager.getUserDataCache().get(uuid); + if (cache != null) cache.cancelRemoval(); + } + + @Override public void beginRetirement() { + for (UserDataCache cache : manager.getUserDataCache().values()) cache.beginRemoval(); + } + + @Override public void cancelRetirement() { + for (UserDataCache cache : manager.getUserDataCache().values()) cache.cancelRemoval(); + } + @Override public void remove(UUID uuid) { UserDataCache cache = manager.getUserDataCache().get(uuid); if (cache != null) { 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 8c7e0c846..c4c6549d5 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 @@ -140,9 +140,15 @@ public void runStorageMaintenance(Runnable operation) { lifecycle.writeLock().lock(); try { requireOpen(); - flushAllInternal(); - cacheOwner.clearAfterFlush(); - operation.run(); + cacheOwner.beginRetirement(); + try { + flushAllInternal(); + cacheOwner.clearAfterFlush(); + operation.run(); + } catch (RuntimeException | Error failure) { + cacheOwner.cancelRetirement(); + throw failure; + } } finally { lifecycle.writeLock().unlock(); cacheOwner.dispatchAllNotifications(); @@ -161,16 +167,22 @@ public void replaceBackend(SqlUserBackend replacement) { retryPendingBackendClose(); if (replacement == backend) return; if (!replacement.isOpen()) throw new IllegalArgumentException("replacement backend is closed"); - flushAllInternal(); - cacheOwner.clearAfterFlush(); - SqlUserBackend previous = backend; - cacheOwner.bindBackend(replacement); - backend = replacement; - try { previous.close(); } - catch (RuntimeException | Error failure) { - pendingBackendClose = previous; - throw failure; - } + cacheOwner.beginRetirement(); + try { + flushAllInternal(); + cacheOwner.clearAfterFlush(); + SqlUserBackend previous = backend; + cacheOwner.bindBackend(replacement); + backend = replacement; + try { previous.close(); } + catch (RuntimeException | Error failure) { + pendingBackendClose = previous; + throw failure; + } + } catch (RuntimeException | Error failure) { + cacheOwner.cancelRetirement(); + throw failure; + } } finally { lifecycle.writeLock().unlock(); cacheOwner.dispatchAllNotifications(); @@ -189,9 +201,15 @@ public void remove(UUID uuid) { cacheOwner.requireBlockingAllowed(); try { userExclusiveAccess(uuid, () -> { - flushInternal(uuid); - backend.user(uuid).delete(backend.storageType()); - cacheOwner.remove(uuid); + cacheOwner.beginRemoval(uuid); + try { + flushInternal(uuid); + backend.user(uuid).delete(backend.storageType()); + cacheOwner.remove(uuid); + } catch (RuntimeException | Error failure) { + cacheOwner.cancelRemoval(uuid); + throw failure; + } return null; }); } finally { cacheOwner.dispatchNotifications(uuid); } @@ -218,6 +236,7 @@ public CompletionStage closeAsync(Executor executor) { lifecycle.writeLock().lock(); try { if (!closed) { + cacheOwner.beginRetirement(); flushAllInternal(); cacheOwner.clearAfterFlush(); cacheOwner.shutdown(); 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 78f81cedd..d0b83c691 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 @@ -63,6 +63,14 @@ default void dispatchNotifications(UUID uuid) {} default void dispatchAllNotifications() {} Set cachedUsers(); + /** Fence direct cache publishers before an exclusive delete begins. */ + default void beginRemoval(UUID uuid) {} + /** Reopen a cache when its exclusive delete did not complete. */ + default void cancelRemoval(UUID uuid) {} + /** Fence direct publishers across a runtime-wide flush and retirement. */ + default void beginRetirement() {} + /** Reopen caches when a runtime-wide transition aborts before retirement. */ + default void cancelRetirement() {} void remove(UUID uuid); void clearAfterFlush(); void shutdown(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheBindingRegressionTest.java index 9377cd7a9..813714b01 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 @@ -433,7 +433,7 @@ private void cacheMiss(UserDataFetchMode mode) { } } - @Test void primaryThreadPendingPopulationQueuesMutationBeforePublishingTheReadSnapshot() throws Exception { + @Test void primaryThreadPendingPopulationDefersMutationIntoTheBoundGeneration() throws Exception { try (Fixture fixture = new Fixture(); var bukkit = mockStatic(Bukkit.class)) { SharedUserDataRuntime runtime = fixture.runtime(); AdvancedCoreUser user = fixture.plugin.getUserManager().getUser(fixture.uuid, false); @@ -447,17 +447,19 @@ private void cacheMiss(UserDataFetchMode mode) { assertDoesNotThrow(() -> new UserData(user).setInt(UserStorage.MYSQL, "Points", 9, true, false)); assertFalse(fixture.first.rows.containsKey(fixture.uuid), "the primary thread must not write SQL"); - assertEquals(2, fixture.tasks.size(), "population must be queued before the delayed flush"); - assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), - "the primary-thread setter must be immediately visible"); + assertEquals(2, fixture.tasks.size(), "population must be queued before the mutation"); + assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).snapshot().containsKey("Points"), + "an unbound placeholder must not accept a gate-bypassing mutation"); fixture.tasks.get(0).run(); - assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), - "the older read publication must retain the immediate mutation"); + assertFalse(fixture.manager.getUserDataCache().get(fixture.uuid).snapshot().containsKey("Points")); fixture.tasks.get(1).run(); assertEquals(9, fixture.manager.getUserDataCache().get(fixture.uuid).getCache().get("Points").getInt(), - "read publication must retain the queued mutation"); + "the admitted worker mutation must publish into the populated cache"); + assertEquals(3, fixture.tasks.size(), "the queued mutation must schedule its shared flush"); + fixture.tasks.get(2).run(); assertEquals(9, fixture.first.points(fixture.uuid)); + fixture.assertNoLegacyWrites(); runtime.close(); } } 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 0957b531a..672254794 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 @@ -244,6 +244,63 @@ void directWritesToTheBoundCacheAlsoRejectRetirement() { assertEquals(3, fixture.first.points(fixture.uuid)); } + @Test + void directPrimaryThreadPublicationCannotRaceAnExclusiveDelete() throws Exception { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.populate(fixture.uuid); + UserDataCache cache = fixture.caches.get(fixture.uuid); + CountDownLatch deleteStarted = new CountDownLatch(1), releaseDelete = new CountDownLatch(1); + fixture.first.beforeDelete = () -> { + deleteStarted.countDown(); + await(releaseDelete); + }; + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future removal = worker.submit(() -> runtime.remove(fixture.uuid)); + await(deleteStarted); + assertFalse(cache.tryAddChangeBeforeDeferredSharedFlush(new UserDataChangeInt("Points", 9))); + releaseDelete.countDown(); + removal.get(5, TimeUnit.SECONDS); + assertFalse(fixture.first.rows.containsKey(fixture.uuid)); + assertFalse(fixture.caches.containsKey(fixture.uuid)); + } finally { + releaseDelete.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + runtime.close(); + } + } + + @Test + void directPrimaryThreadPublicationCannotRaceBackendReplacement() throws Exception { + Fixture fixture = new Fixture(); + SharedUserDataRuntime runtime = fixture.runtime(); + runtime.queueChange(fixture.uuid, "Points", new DataValueInt(7)); + UserDataCache cache = fixture.caches.get(fixture.uuid); + CountDownLatch writeStarted = new CountDownLatch(1), releaseWrite = new CountDownLatch(1); + fixture.first.beforeWrite = () -> { + writeStarted.countDown(); + await(releaseWrite); + }; + MemoryBackend replacement = new MemoryBackend(UserStorage.MYSQL); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future replacing = worker.submit(() -> runtime.replaceBackend(replacement)); + await(writeStarted); + assertFalse(cache.tryAddChangeBeforeDeferredSharedFlush(new UserDataChangeInt("Points", 9))); + releaseWrite.countDown(); + replacing.get(5, TimeUnit.SECONDS); + assertEquals(7, fixture.first.points(fixture.uuid)); + assertFalse(fixture.caches.containsKey(fixture.uuid)); + } finally { + releaseWrite.countDown(); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + runtime.close(); + } + } + @Test void shutdownCancelsDelayedTimerWorkWithoutAwaitingIt() throws Exception { Fixture fixture = new Fixture(); @@ -303,6 +360,7 @@ private static final class MemoryBackend implements SqlUserBackend { final Map> rows = new ConcurrentHashMap<>(); volatile boolean open = true; volatile Runnable beforeWrite = () -> {}; + volatile Runnable beforeDelete = () -> {}; MemoryBackend(UserStorage type) { this.type = type; } int points(UUID uuid) { return rows.get(uuid).get("Points").getInt(); } public UserStorage storageType() { return type; } @@ -318,7 +376,7 @@ public List readRow(UserStorage requested) { return result; } public boolean contains(UserStorage requested) { return rows.containsKey(uuid); } - public void delete(UserStorage requested) { rows.remove(uuid); } + public void delete(UserStorage requested) { beforeDelete.run(); rows.remove(uuid); } public void write(UserStorage requested, String key, DataValue value) { writeValues(requested, new HashMap<>(Map.of(key, value))); } From 27720870a83e0f393497c3a0928e3a4c2c4f7ed7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:27:46 -0600 Subject: [PATCH 31/34] Bound deferred storage shutdown --- .../advancedcore/api/user/UserData.java | 15 +++-- .../api/user/usercache/UserDataManager.java | 16 ++--- .../advancedcore/command/CommandLoader.java | 20 ++++-- .../core/platform/RuntimePlatform.java | 3 + .../core/runtime/AdvancedCoreRuntime.java | 67 ++++++++++++++----- .../tests/lifecycle/CoreRuntimeTest.java | 40 +++++++++-- .../SharedCacheCleanupPrimaryThreadTest.java | 27 +++++++- 7 files changed, 146 insertions(+), 42 deletions(-) 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 7c56650b2..fd0828f09 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 @@ -217,8 +217,7 @@ public ArrayList getKeys() { public ArrayList getKeys(UserStorage storage) { ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); - UserDataCache sharedCache = primaryThreadSharedCache(storage); - if (sharedCache != null) return new ArrayList<>(sharedCache.snapshot().keySet()); + rejectPrimaryThreadPersistedRowRead(storage); return sqlData.getKeys(storage); } @@ -381,8 +380,7 @@ public HashMap getValues() { public HashMap getValues(UserStorage storage) { ensureRequestedStorageIsNotOwnedByAnotherSharedBackend(storage); - UserDataCache sharedCache = primaryThreadSharedCache(storage); - if (sharedCache != null) return sharedCache.snapshot(); + rejectPrimaryThreadPersistedRowRead(storage); return convert(sqlData.readRow(storage)); } @@ -663,6 +661,15 @@ private UserDataCache primaryThreadSharedCache(UserStorage storage) { } return cache; } + + /** Bulk row APIs expose persisted storage, not defaults or pending cache values. */ + private void rejectPrimaryThreadPersistedRowRead(UserStorage storage) { + UserDataManager manager = sharedDataManager(); + if (manager != null && manager.usesSharedSqlStorage(storage) && manager.mustDeferSharedStorageAccess()) { + throw new IllegalStateException( + "Shared persisted user data must be read on a worker; use deferSharedStorageResult"); + } + } public void setStringList(final String key, final ArrayList value) { setStringList(key, value, true); 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 d975ae01e..4cf8afced 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 @@ -503,6 +503,14 @@ private void cacheUserSynchronously(UUID uuid, boolean traceDevelopmentCall, boo catch (Error failure) { errorFailure = failure; } finally { cacheMapLifecycle.readLock().unlock(); } boolean current = population == null || finishSharedCachePopulation(population, runtimeFailure == null && errorFailure == null); + if (runtimeFailure != null) { + if (current) notifyCacheChangesAfterFailure(refreshed, runtimeFailure); + throw runtimeFailure; + } + if (errorFailure != null) { + if (current) notifyCacheChangesAfterFailure(refreshed, errorFailure); + throw errorFailure; + } if (!current || !populated[0]) { // A synchronous caller whose read was fenced before it began still expects // one attempt in the newly published generation. Do not retry a read that @@ -513,14 +521,6 @@ private void cacheUserSynchronously(UUID uuid, boolean traceDevelopmentCall, boo } return; } - if (runtimeFailure != null) { - notifyCacheChangesAfterFailure(refreshed, runtimeFailure); - throw runtimeFailure; - } - if (errorFailure != null) { - notifyCacheChangesAfterFailure(refreshed, errorFailure); - throw errorFailure; - } notifyCacheChanges(refreshed); } diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java index e0482c6c3..095518498 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java @@ -565,7 +565,8 @@ public void execute(CommandSender sender, String[] args) { if (plugin.getUserManager().getDataManager().deferSharedStorageResult(user.getData()::getValues, values -> values.forEach((key, value) -> sendMessage(sender, "&c&l" + key + " &c" + value.toString())), - failure -> sendMessage(sender, "&cUnable to read user data; check the server log."))) return; + failure -> sendMessage(sender, "&cUnable to read user data; check the server log."), + callbackOwner(sender))) return; for (Entry entry : user.getData().getValues().entrySet()) { sendMessage(sender, "&c&l" + entry.getKey() + " &c" + entry.getValue().toString()); } @@ -678,8 +679,8 @@ public void execute(CommandSender sender, String[] args) { private void startStorageConversion(CommandSender sender, UserStorage from, UserStorage to) { sender.sendMessage(MessageAPI.colorize("&cStarting convert from " + from + " to " + to)); - plugin.convertDataStorageAsync(from, to).whenComplete((ignored, failure) -> - plugin.getBukkitScheduler().runTask(plugin, () -> { + plugin.convertDataStorageAsync(from, to).whenComplete((ignored, failure) -> { + Runnable completion = () -> { if (failure == null) { sender.sendMessage(MessageAPI.colorize("&cFinished converting")); return; @@ -689,7 +690,11 @@ private void startStorageConversion(CommandSender sender, UserStorage from, User plugin.getLogger().severe("User storage conversion failed (" + failure.getClass().getSimpleName() + ")"); sender.sendMessage(MessageAPI.colorize("&cUser storage conversion failed; see the server log")); - })); + }; + org.bukkit.entity.Entity owner = callbackOwner(sender); + if (owner == null) plugin.getBukkitScheduler().runTask(plugin, completion); + else plugin.getBukkitScheduler().runTask(plugin, completion, owner); + }); } /** Complete destructive user removal before reporting success or clearing identity mappings. */ @@ -705,7 +710,8 @@ private void removeUserData(CommandSender sender, String identifier, AdvancedCor }; java.util.function.Consumer failed = ignored -> sender.sendMessage(MessageAPI.colorize("&cUnable to remove " + identifier + "; check the server log.")); - if (plugin.getUserManager().getDataManager().deferSharedStorageResult(remove, succeeded, failed)) return; + if (plugin.getUserManager().getDataManager().deferSharedStorageResult(remove, succeeded, failed, + callbackOwner(sender))) return; try { succeeded.accept(remove.get()); } catch (RuntimeException failure) { plugin.getLogger().severe("User removal failed (" + failure.getClass().getSimpleName() + ")"); @@ -713,6 +719,10 @@ private void removeUserData(CommandSender sender, String identifier, AdvancedCor } } + private org.bukkit.entity.Entity callbackOwner(CommandSender sender) { + return sender instanceof Player player ? player : null; + } + /** * Gets the basic commands. * diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java index c76ca9c06..8439b2795 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/platform/RuntimePlatform.java @@ -37,6 +37,9 @@ default CompletionStage beforeExecutorShutdownCompletion() { */ default boolean canBlockForPreExecutorShutdown() { return true; } + /** Maximum time a non-blocking lifecycle waits before forcing its storage worker. */ + default long deferredShutdownTimeoutMillis() { return 5_000; } + List afterExecutorGrace(); List afterExecutorShutdown(); void info(String message); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java index 81b40f4b3..74d1f0779 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/core/runtime/AdvancedCoreRuntime.java @@ -2,12 +2,14 @@ import java.util.List; import java.util.Objects; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import java.util.concurrent.CompletionStage; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicBoolean; import com.bencodez.advancedcore.core.platform.RuntimePlatform; import com.bencodez.advancedcore.core.platform.RuntimePlatform.Cleanup; @@ -136,33 +138,62 @@ private CleanupState awaitCleanup(CompletionStage completion, String compo } } - /** Finish every teardown phase only after deferred storage work has completed. */ + /** Finish platform teardown now and bound the remaining storage-worker retirement. */ private void finishDeferredCleanup(CompletionStage completion, String component, ExecutorGrace grace) { ScheduledExecutorService timer = platform.getTimer(); + // Bukkit/Folia-facing cleanup must finish on the lifecycle thread before + // onDisable returns. Only storage-executor retirement continues later. + finishDeferredPlatformCleanup(grace); + AtomicBoolean finished = new AtomicBoolean(); completion.whenComplete((ignored, failure) -> { - boolean timerForced = failure != null; - if (failure == null) { - shutdown(timer); - } else { + if (!finished.compareAndSet(false, true)) return; + if (failure == null) shutdown(timer); + else { Throwable cause = failure instanceof CompletionException && failure.getCause() != null ? failure.getCause() : failure; platform.cleanupFailed(component, cause); shutdownNow(timer); } - Runnable continuation = () -> { - await(timer, failure == null ? 2 : 1, TimeUnit.SECONDS); - finishAfterExecutorGrace(grace, timerForced); - }; - // Completion normally runs on the storage timer itself. Let that callback - // return before awaiting the timer, otherwise it waits for its own task. - Thread shutdownThread = new Thread(continuation, "AdvancedCore-Shutdown"); - shutdownThread.setDaemon(true); - try { shutdownThread.start(); } - catch (RuntimeException | Error startFailure) { - platform.cleanupFailed("deferred shutdown continuation", startFailure); - continuation.run(); - } + finishDeferredStorageTimer(timer, failure != null); }); + long timeoutMillis = Math.max(1, platform.deferredShutdownTimeoutMillis()); + Runnable timeout = () -> { + if (!finished.compareAndSet(false, true)) return; + platform.cleanupFailed(component, new TimeoutException( + "Deferred storage retirement exceeded " + timeoutMillis + " ms")); + shutdownNow(timer); + finishDeferredStorageTimer(timer, true); + }; + try { CompletableFuture.delayedExecutor(timeoutMillis, TimeUnit.MILLISECONDS).execute(timeout); } + catch (RuntimeException | Error schedulingFailure) { + platform.cleanupFailed("deferred storage shutdown watchdog", schedulingFailure); + timeout.run(); + } + } + + private void finishDeferredPlatformCleanup(ExecutorGrace grace) { + clean(platform.afterExecutorGrace()); + shutdownNow(platform.getLoginTimer()); + shutdownNow(grace.timeTimer()); + shutdownNow(platform.getInventoryTimer()); + await(platform.getLoginTimer(), 1, TimeUnit.SECONDS); + await(grace.timeTimer(), 1, TimeUnit.SECONDS); + await(platform.getInventoryTimer(), 1, TimeUnit.SECONDS); + clean(platform.afterExecutorShutdown()); + } + + private void finishDeferredStorageTimer(ScheduledExecutorService timer, boolean forced) { + Runnable retirement = () -> { + await(timer, forced ? 1 : 2, TimeUnit.SECONDS); + if (!forced && timer != null && !timer.isTerminated()) shutdownNow(timer); + }; + Thread shutdownThread = new Thread(retirement, "AdvancedCore-Storage-Shutdown"); + shutdownThread.setDaemon(true); + try { shutdownThread.start(); } + catch (RuntimeException | Error startFailure) { + platform.cleanupFailed("deferred storage shutdown continuation", startFailure); + shutdownNow(timer); + } } public static void shutdown(ScheduledExecutorService executor) { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java index 291206ac8..31e15ef1c 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/lifecycle/CoreRuntimeTest.java @@ -32,6 +32,7 @@ private RuntimePlatform platform() { when(platform.afterExecutorGrace()).thenReturn(List.of()); when(platform.afterExecutorShutdown()).thenReturn(List.of()); when(platform.canBlockForPreExecutorShutdown()).thenReturn(true); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(5_000L); return platform; } @@ -122,21 +123,52 @@ private ScheduledExecutorService executor(String name, List events) thro when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); when(platform.getTimer()).thenReturn(timer); + java.util.concurrent.atomic.AtomicReference cleanupThread = new java.util.concurrent.atomic.AtomicReference<>(); when(platform.afterExecutorGrace()).thenReturn(List.of( new Cleanup("reward", () -> events.add("reward")))); when(platform.afterExecutorShutdown()).thenReturn(List.of( - new Cleanup("unload", () -> events.add("unload")))); + new Cleanup("unload", () -> { cleanupThread.set(Thread.currentThread()); events.add("unload"); }))); + Thread lifecycleThread = Thread.currentThread(); assertDoesNotThrow(() -> new AdvancedCoreRuntime(platform).shutdown()); verify(timer, never()).shutdown(); verify(timer, never()).shutdownNow(); verify(timer, never()).awaitTermination(anyLong(), any()); - assertTrue(events.isEmpty(), "unload hooks must wait for storage retirement"); + assertEquals(List.of("reward", "unload"), events); + assertSame(lifecycleThread, cleanupThread.get(), + "Bukkit-facing cleanup must finish on the lifecycle thread before disable returns"); retiring.complete(null); long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); - while (!events.contains("unload") && System.nanoTime() < deadline) Thread.yield(); + while (mockingDetails(timer).getInvocations().stream() + .noneMatch(invocation -> invocation.getMethod().getName().equals("shutdown")) + && System.nanoTime() < deadline) Thread.yield(); verify(timer).shutdown(); - assertEquals(List.of("reward", "unload"), events); + assertEquals(List.of("reward", "unload"), events, "deferred completion must not repeat cleanup"); + } + + @Test void deferredRetirementTimeoutForcesStorageWorkerWithoutRepeatingPlatformCleanup() { + RuntimePlatform platform = platform(); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + CompletableFuture retiring = new CompletableFuture<>(); + List events = new java.util.concurrent.CopyOnWriteArrayList<>(); + when(platform.beforeExecutorShutdownCompletion()).thenReturn(retiring); + when(platform.canBlockForPreExecutorShutdown()).thenReturn(false); + when(platform.deferredShutdownTimeoutMillis()).thenReturn(20L); + when(platform.getTimer()).thenReturn(timer); + when(platform.afterExecutorShutdown()).thenReturn(List.of( + new Cleanup("unload", () -> events.add("unload")))); + + new AdvancedCoreRuntime(platform).shutdown(); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (mockingDetails(timer).getInvocations().stream() + .noneMatch(invocation -> invocation.getMethod().getName().equals("shutdownNow")) + && System.nanoTime() < deadline) Thread.yield(); + + verify(timer).shutdownNow(); + verify(platform).cleanupFailed(eq("pre-executor shutdown"), any(java.util.concurrent.TimeoutException.class)); + assertEquals(List.of("unload"), events); + retiring.complete(null); + assertEquals(List.of("unload"), events); } @Test void deferredRetirementFailureTerminatesItsWorkerAfterReportingTheFailure() { diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index ef0eecd39..fab9b5d7b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -51,7 +51,7 @@ class SharedCacheCleanupPrimaryThreadTest { @Test - void primaryThreadBulkReadsUseTheCompletedSharedCacheSnapshot() { + void primaryThreadPersistedBulkReadsRequireWorkerDeferral() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); UserManager users = mock(UserManager.class); when(plugin.getUserManager()).thenReturn(users); @@ -72,8 +72,8 @@ void primaryThreadBulkReadsUseTheCompletedSharedCacheSnapshot() { try (var bukkit = mockStatic(Bukkit.class)) { bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); - assertEquals(Set.of("Points"), Set.copyOf(data.getKeys())); - assertEquals(7, data.getValues().get("Points").getInt()); + IllegalStateException keysFailure = assertThrows(IllegalStateException.class, data::getKeys); + IllegalStateException valuesFailure = assertThrows(IllegalStateException.class, data::getValues); assertTrue(data.hasData()); IllegalStateException intFailure = assertThrows(IllegalStateException.class, () -> data.getInt(UserStorage.MYSQL, "Points", -1, @@ -83,6 +83,8 @@ void primaryThreadBulkReadsUseTheCompletedSharedCacheSnapshot() { com.bencodez.advancedcore.api.user.UserDataFetchMode.NO_CACHE)); assertTrue(intFailure.getMessage().contains("defer")); assertTrue(stringFailure.getMessage().contains("defer")); + assertTrue(keysFailure.getMessage().contains("defer")); + assertTrue(valuesFailure.getMessage().contains("defer")); verify(backend, never()).user(any(UUID.class)); } manager.getTimer().shutdownNow(); @@ -250,6 +252,25 @@ void failedPrimaryThreadPopulationIsRetainedBeforeNotificationDelivery() throws } } + @Test + void synchronousPopulationRethrowsStorageFailure() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + IllegalStateException failure = new IllegalStateException("storage unavailable"); + when(plugin.getUserManager().getUser(uuid, false).getUserData().getKeys()).thenThrow(failure); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + try { + assertSame(failure, assertThrows(IllegalStateException.class, + () -> manager.cacheUser(uuid, null))); + assertFalse(manager.containsKey(uuid)); + } finally { + manager.getTimer().shutdownNow(); + } + } + @Test void primaryThreadCachePopulationIsQueuedAndGetCacheReturnsAPopulationPlaceholder() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); From 4b58edb5926ebbe2b884b63adadeb46b6c64a521 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:41:03 -0600 Subject: [PATCH 32/34] Harden shared user storage lifecycle --- .../advancedcore/AdvancedCorePlugin.java | 297 +++++++++++++++--- .../advancedcore/api/rewards/Reward.java | 6 +- .../api/user/AdvancedCoreUser.java | 20 +- .../advancedcore/api/user/UserManager.java | 244 ++++++++------ .../api/user/usercache/UserDataManager.java | 127 +++++++- .../user/runtime/BukkitUserCacheOwner.java | 6 +- .../user/storage/BukkitSqlUserBackend.java | 36 ++- .../advancedcore/command/CommandLoader.java | 2 +- .../user/runtime/SharedUserDataRuntime.java | 27 +- .../core/user/runtime/UserCacheOwner.java | 3 + .../rewards/RewardAsyncInjectionTest.java | 38 +++ .../SharedCacheBindingRegressionTest.java | 5 + .../SharedCacheCleanupPrimaryThreadTest.java | 41 +++ .../user/SharedRouteReplacementRaceTest.java | 1 + .../tests/user/SharedUserDataRuntimeTest.java | 52 +++ .../SharedUserStorageReloadSafetyTest.java | 186 ++++++++++- 16 files changed, 929 insertions(+), 162 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index 70f649c9f..cedc1ce57 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -51,6 +51,7 @@ import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.advancedcore.bukkit.user.runtime.BukkitUserRuntimeBootstrap; +import com.bencodez.advancedcore.bukkit.user.storage.BukkitSqlUserBackend; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.advancedcore.command.CommandLoader; @@ -189,7 +190,24 @@ public static void setInstance(AdvancedCorePlugin plugin) { @Getter private CMIHandler cmiHandle; - private Database database; + private Database database; + /** + * A coherent native-owner snapshot for callers which need both the selected + * storage kind and its provider. Shared-runtime replacement publishes this + * as one volatile value; callers must not combine a separately observed + * storage type with the mutable provider fields. + */ + public record UserStorageOwner(UserStorage storageType, MySQL mysql, UserTable table) { + public UserStorageOwner { + if (storageType == null) throw new IllegalArgumentException("storageType"); + if (storageType == UserStorage.MYSQL && mysql == null) throw new IllegalArgumentException("mysql"); + if (storageType == UserStorage.SQLITE && table == null) throw new IllegalArgumentException("table"); + } + } + private volatile UserStorageOwner nativeUserStorageOwner; + /** Coalesces public storage reload requests while a replacement is being prepared. */ + private Object userStorageReloadLock = new Object(); + private CompletionStage userStorageReload; /** * Handler for full inventory management. @@ -631,7 +649,7 @@ public void extraDebug(String debug) { * * @return the user table, or null if not using SQLite */ - public UserTable getSQLiteUserTable() { + public UserTable getSQLiteUserTable() { if (database == null && loadUserData) { loadUserAPI(getStorageType()); } @@ -642,17 +660,38 @@ public UserTable getSQLiteUserTable() { } } } - return null; - } + return null; + } + + /** + * Returns the current native provider as one immutable observation. This is + * intentionally separate from the legacy individual getters: user-facing + * bulk APIs use this snapshot while a shared route is being replaced. + */ + public UserStorageOwner getNativeUserStorageOwner() { + UserStorageOwner owner = nativeUserStorageOwner; + if (owner != null) return owner; + UserStorage configured = getOptions().getStorageType(); + if (configured == UserStorage.MYSQL && mysql != null) return new UserStorageOwner(configured, mysql, null); + if (configured == UserStorage.SQLITE && database != null) { + for (Table table : database.getTables()) { + if (table instanceof UserTable userTable) return new UserStorageOwner(configured, null, userTable); + } + } + return null; + } /** * Gets the current storage type configuration. * * @return the storage type */ - public UserStorage getStorageType() { - return getOptions().getStorageType(); - } + public UserStorage getStorageType() { + UserStorage configured = getOptions().getStorageType(); + UserManager loadedUsers = getLoadedUserManager(); + return loadedUsers == null ? configured + : loadedUsers.getDataManager().effectiveStorageType(configured); + } /** * Gets the user manager instance. @@ -1072,8 +1111,9 @@ public void loadUserAPI(UserStorage storageType) { Column key = new Column("uuid", DataType.STRING); columns.add(key); UserTable table = new UserTable(this, "Users", columns, key); - database = new Database(this, "Users", table); - table.addCustomColumns(); + database = new Database(this, "Users", table); + table.addCustomColumns(); + nativeUserStorageOwner = new UserStorageOwner(UserStorage.SQLITE, null, table); } else if (storageType.equals(UserStorage.MYSQL)) { if (getOptions().getYmlConfig().getData().contains("Database")) { setMysql(new MySQL(javaPlugin, javaPlugin.getName() + "_Users", @@ -1256,36 +1296,212 @@ public void reloadAdvancedCore() { reloadAdvancedCore(false); } - /** - * Reloads AdvancedCore configuration. - * - * @param userStorage whether to reload user storage - */ + /** + * Starts an AdvancedCore configuration reload. + * + *

When {@code userStorage} is true and a shared runtime is active, this + * method only starts the non-blocking reload; it returns before storage has + * been flushed, replaced, or confirmed. It deliberately has no success + * signal. Call {@link #reloadAdvancedCoreAsync(boolean)} when a caller must + * continue, notify an administrator, or report status after completion. + * + * @param userStorage whether to reload user storage + */ public void reloadAdvancedCore(boolean userStorage) { + if (userStorage && hasActiveSharedUserRuntime()) { + reloadAdvancedCoreAsync(true).whenComplete((ignored, failure) -> { + if (failure != null) { + getLogger().warning("User storage reload did not complete: " + failure.getMessage()); + debug(failure); + } + }); + return; + } + reloadAdvancedCoreNow(userStorage); + } + + /** + * Reload AdvancedCore without making a Bukkit thread wait for a shared user + * storage flush or a database connection. Callers that need to report a + * confirmed storage reload should await this stage rather than assuming that + * the legacy void overload has completed synchronously. + * + * @param userStorage whether to reload user storage + * @return completion of the reload, including the shared backend replacement + */ + public CompletionStage reloadAdvancedCoreAsync(boolean userStorage) { + if (!userStorage || !hasActiveSharedUserRuntime()) { + CompletableFuture completion = new CompletableFuture<>(); + try { + reloadAdvancedCoreNow(userStorage); + completion.complete(null); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + return completion; + } + synchronized (userStorageReloadLock()) { + if (userStorageReload != null && !userStorageReload.toCompletableFuture().isDone()) return userStorageReload; + CompletableFuture completion = new CompletableFuture<>(); + userStorageReload = completion; + try { + getBukkitScheduler().runTask(this, () -> beginSharedUserStorageReload(completion)); + } catch (RuntimeException | Error failure) { + completion.completeExceptionally(failure); + userStorageReload = null; + } + return completion; + } + } + + private boolean hasActiveSharedUserRuntime() { + UserManager users = getLoadedUserManager(); + return users != null && users.getDataManager().hasSharedRuntime(); + } + + private void beginSharedUserStorageReload(CompletableFuture completion) { + try { + getServerDataFile().reloadData(); + rewardHandler.loadRewards(); + getOptions().load(this); + getBukkitScheduler().runTaskAsynchronously(this, () -> replaceSharedUserStorage(completion)); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + clearSharedUserStorageReload(completion); + } + } + + private void replaceSharedUserStorage(CompletableFuture completion) { + UserStorageReplacement replacement = null; + try { + replacement = prepareUserStorageReplacement(getOptions().getStorageType()); + UserStorageReplacement prepared = replacement; + getUserManager().getDataManager().replaceSharedSqlBackendAsync(prepared.backend(), + () -> installUserStorageReplacement(prepared)).whenComplete((ignored, failure) -> { + if (failure != null) { + // SharedUserDataRuntime reports an exception here only before it + // publishes the replacement route. A post-publication old-owner close + // is retained there for retry and deliberately completes successfully. + prepared.closeUnpublished(); + completion.completeExceptionally(failure); + clearSharedUserStorageReload(completion); + return; + } + try { + getBukkitScheduler().runTask(this, () -> completeSharedUserStorageReload(completion)); + } catch (Throwable completionFailure) { + completion.completeExceptionally(completionFailure); + clearSharedUserStorageReload(completion); + } + }); + } catch (Throwable failure) { + if (replacement != null) replacement.closeUnpublished(); + completion.completeExceptionally(failure); + clearSharedUserStorageReload(completion); + } + } + + private void completeSharedUserStorageReload(CompletableFuture completion) { + try { + finishReloadAdvancedCore(); + completion.complete(null); + } catch (Throwable failure) { completion.completeExceptionally(failure); } + finally { clearSharedUserStorageReload(completion); } + } + + private void clearSharedUserStorageReload(CompletableFuture completion) { + synchronized (userStorageReloadLock()) { + if (userStorageReload == completion) userStorageReload = null; + } + } + + private Object userStorageReloadLock() { + Object lock = userStorageReloadLock; + if (lock != null) return lock; + synchronized (this) { + if (userStorageReloadLock == null) userStorageReloadLock = new Object(); + return userStorageReloadLock; + } + } + + private UserStorageReplacement prepareUserStorageReplacement(UserStorage storageType) { + if (storageType == null) throw new IllegalArgumentException("User storage must be SQLITE or MYSQL"); + if (storageType == UserStorage.SQLITE) { + ArrayList columns = new ArrayList<>(); + Column key = new Column("uuid", DataType.STRING); + columns.add(key); + UserTable table = new UserTable(this, "Users", columns, key); + Database replacementDatabase = new Database(this, "Users", table); + table.addCustomColumns(); + return new UserStorageReplacement(storageType, replacementDatabase, null, + new BukkitSqlUserBackend(this, storageType, null, table)); + } + ConfigurationSection section = getOptions().getYmlConfig().getData() + .getConfigurationSection(getOptions().getYmlConfig().getData().contains("Database") ? "Database" : "MySQL"); + if (section == null) throw new IllegalStateException("MySQL user storage configuration is missing"); + MySQL replacementMysql = new MySQL(javaPlugin, javaPlugin.getName() + "_Users", section); + return new UserStorageReplacement(storageType, null, replacementMysql, + new BukkitSqlUserBackend(this, storageType, replacementMysql, null)); + } + + private void installUserStorageReplacement(UserStorageReplacement replacement) { + MySQL previousMysql = mysql; + Database previousDatabase = database; + mysql = replacement.mysql(); + database = replacement.database(); + // Publish only after both legacy fields have been assigned. The shared route + // picks this exact snapshot up in the same lifecycle write admission. + nativeUserStorageOwner = replacement.owner(); + if (previousMysql != null && previousMysql != mysql) { + try { previousMysql.close(); } + catch (RuntimeException | Error closeFailure) { debug(closeFailure); } + } + if (previousDatabase != null && previousDatabase != database) { + try { previousDatabase.getDB().closeConnection(); } + catch (RuntimeException | Error closeFailure) { debug(closeFailure); } + } + } + + private void reloadAdvancedCoreNow(boolean userStorage) { if (userStorage) requireUserStorageMaintenanceWindow(); getServerDataFile().reloadData(); - rewardHandler.loadRewards(); - loadConfig(userStorage); - - if (userStorage) { - getUserManager().getDataManager().clearCache(); - if (getStorageType().equals(UserStorage.MYSQL) && getMysql() != null) { - getMysql().clearCacheBasic(); - } - } - timeChecker.update(); - TabCompleteHandler.getInstance().reload(); - TabCompleteHandler.getInstance().loadTabCompleteOptions(); - getRewardHandler().checkSubRewards(); - - if (skullCacheHandler != null) { - if (!getOptions().getSkullProfileAPIURL().isEmpty()) { - debug("Setting API profile URL to " + getOptions().getSkullProfileAPIURL()); - skullCacheHandler.changeApiProfileURL(getOptions().getSkullProfileAPIURL()); - } - getSkullCacheHandler().setBedrockPrefix(getOptions().getBedrockPlayerPrefix()); - } - } + rewardHandler.loadRewards(); + loadConfig(userStorage); + if (userStorage) { + getUserManager().getDataManager().clearCache(); + if (getStorageType().equals(UserStorage.MYSQL) && getMysql() != null) getMysql().clearCacheBasic(); + } + finishReloadAdvancedCore(); + } + + private void finishReloadAdvancedCore() { + timeChecker.update(); + TabCompleteHandler.getInstance().reload(); + TabCompleteHandler.getInstance().loadTabCompleteOptions(); + getRewardHandler().checkSubRewards(); + if (skullCacheHandler != null) { + if (!getOptions().getSkullProfileAPIURL().isEmpty()) { + debug("Setting API profile URL to " + getOptions().getSkullProfileAPIURL()); + skullCacheHandler.changeApiProfileURL(getOptions().getSkullProfileAPIURL()); + } + getSkullCacheHandler().setBedrockPrefix(getOptions().getBedrockPlayerPrefix()); + } + } + + private record UserStorageReplacement(UserStorage storageType, Database database, MySQL mysql, + BukkitSqlUserBackend backend) { + private UserStorageOwner owner() { + return new UserStorageOwner(storageType, mysql, database == null ? null : findUserTable(database)); + } + + private static UserTable findUserTable(Database database) { + for (Table table : database.getTables()) if (table instanceof UserTable userTable) return userTable; + throw new IllegalStateException("Replacement SQLite user table is unavailable"); + } + private void closeUnpublished() { + backend.close(); + if (mysql != null) mysql.close(); + if (database != null) database.getDB().closeConnection(); + } + } /** * @param configData the configData to set @@ -1325,13 +1541,14 @@ public void setConfigData(YMLConfig ymlConfig) { * * @param mysql the mysql connection to set */ - public void setMysql(MySQL mysql) { + public void setMysql(MySQL mysql) { if (this.mysql != null) { this.mysql.close(); this.mysql = null; - } - this.mysql = mysql; - } + } + this.mysql = mysql; + if (mysql != null) nativeUserStorageOwner = new UserStorageOwner(UserStorage.MYSQL, mysql, null); + } /** * Runs user startup tasks. diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/rewards/Reward.java index 8b8e9802e..d24674b89 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 @@ -1741,6 +1741,10 @@ public void giveRewardUser(AdvancedCoreUser user, HashMap phs, R */ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap phs, RewardOptions rewardOptions) { + // This public API may hand preparation to the player's scheduler. Preserve the + // caller's values before that boundary so a reused mutable map cannot alter a + // queued reward or its durable replay checkpoint. + HashMap placeholders = phs == null ? new HashMap<>() : new HashMap<>(phs); ReplayState replayState = replayStateFor(rewardOptions); List orderedRewards = orderedInjectedRewards(); String registryFingerprint = injectionRegistryFingerprint(orderedRewards); @@ -1765,7 +1769,7 @@ public CompletionStage giveRewardUserAsync(AdvancedCoreUser user, HashMap< final String stableReplayKey = replayKey; final String stableOccurrenceId = occurrenceId; return requestOnServerThread(user, - () -> prepareAndGiveRewardUserAsync(user, phs, rewardOptions, replayState, stableReplayKey, + () -> prepareAndGiveRewardUserAsync(user, placeholders, rewardOptions, replayState, stableReplayKey, stableOccurrenceId, registryFingerprint, !orderedRewards.isEmpty())); } 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 4b9dc5663..bbb27ceed 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 @@ -529,7 +529,7 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid) { this.plugin = plugin; this.uuid = uuid.toString(); loadData(); - setPlayerName(PlayerManager.getInstance().getPlayerName(this, this.uuid, false)); + loadPlayerNameWithoutBlocking(false); } /** @@ -546,7 +546,7 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid, boolean loadName) this.loadName = loadName; loadData(); if (this.loadName) { - setPlayerName(PlayerManager.getInstance().getPlayerName(this, this.uuid)); + loadPlayerNameWithoutBlocking(true); } } @@ -568,11 +568,25 @@ public AdvancedCoreUser(AdvancedCorePlugin plugin, UUID uuid, boolean loadName, loadData(); } if (this.loadName) { - setPlayerName(PlayerManager.getInstance().getPlayerName(this, this.uuid)); + loadPlayerNameWithoutBlocking(true); } } + /** Resolve a UUID-backed name without performing shared SQL on Bukkit's primary thread. */ + private void loadPlayerNameWithoutBlocking(boolean useCache) { + String cached = com.bencodez.advancedcore.api.player.UuidLookup.getInstance().getCachedName(uuid); + if (!cached.isEmpty()) { + setPlayerName(cached); + return; + } + UserDataManager manager = plugin.getUserManager().getDataManager(); + if (manager.deferSharedStorageResult( + () -> PlayerManager.getInstance().getPlayerName(this, uuid, useCache), + this::setPlayerName, plugin::debug)) return; + setPlayerName(PlayerManager.getInstance().getPlayerName(this, uuid, useCache)); + } + /** * Instantiates a new user. * diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java index 28007d080..cab7c07a7 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java @@ -1,21 +1,24 @@ package com.bencodez.advancedcore.api.user; - + import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map.Entry; import java.util.UUID; -import java.util.function.BiConsumer; -import java.util.function.Consumer; +import java.util.function.BiConsumer; +import java.util.function.Consumer; +import java.util.function.Function; import org.bukkit.OfflinePlayer; import org.bukkit.entity.Player; import com.bencodez.advancedcore.AdvancedCorePlugin; import com.bencodez.advancedcore.api.player.UuidLookup; -import com.bencodez.advancedcore.api.user.usercache.UserDataManager; -import com.bencodez.advancedcore.api.user.validation.UserValidationFactory; -import com.bencodez.advancedcore.api.user.validation.UserValidationService; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.validation.UserValidationFactory; +import com.bencodez.advancedcore.api.user.validation.UserValidationService; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.advancedcore.api.user.userstorage.sql.UserTable; import com.bencodez.simpleapi.array.ArrayUtils; import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.sql.DataType; @@ -42,66 +45,106 @@ public class UserManager { @Getter private UserValidationService validationService; - public UserManager(AdvancedCorePlugin plugin) { + public UserManager(AdvancedCorePlugin plugin) { this.plugin = plugin; load(); - } - - public void copyColumnData(String columnFromName, String columnToName) { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().copyColumnData(columnFromName, columnToName, DataType.STRING); - } else if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - plugin.getSQLiteUserTable().copyColumnData(columnFromName, columnToName, DataType.STRING); - } - } - - public List getAllColumns() { - UserStorage storage = plugin.getStorageType(); - if (storage.equals(UserStorage.SQLITE)) { - return plugin.getSQLiteUserTable().getColumnsString(); - } - if (storage.equals(UserStorage.MYSQL)) { - return plugin.getMysql().getColumns(); - } - return new ArrayList<>(); - } + } + + /** + * Capture the type and native owner as one observation. During shared-runtime + * replacement the manager publishes this pair atomically with its route; + * callers must not combine plugin.getStorageType() with a later provider read. + */ + private T withActiveStorageOwner(Function operation) { + return dataManager == null ? operation.apply(plugin.getNativeUserStorageOwner()) + : dataManager.withSharedNativeUserStorage(operation); + } + + private UserStorage activeStorageType(AdvancedCorePlugin.UserStorageOwner owner) { + return owner == null ? plugin.getStorageType() : owner.storageType(); + } + + private MySQL activeMysql(AdvancedCorePlugin.UserStorageOwner owner) { + return owner != null && owner.storageType() == UserStorage.MYSQL && owner.mysql() != null + ? owner.mysql() : plugin.getMysql(); + } + + private UserTable activeTable(AdvancedCorePlugin.UserStorageOwner owner) { + return owner != null && owner.storageType() == UserStorage.SQLITE && owner.table() != null + ? owner.table() : plugin.getSQLiteUserTable(); + } + + private AdvancedCorePlugin.UserStorageOwner ownerFor(UserStorage storage, AdvancedCorePlugin.UserStorageOwner owner) { + if (owner != null && owner.storageType() != storage && dataManager != null && dataManager.hasSharedSqlBackend() + && !dataManager.isStorageMaintenanceActive()) { + throw new IllegalStateException("Cannot access " + storage + + " user storage while the shared runtime owns " + owner.storageType()); + } + return owner != null && owner.storageType() == storage ? owner : null; + } + + public void copyColumnData(String columnFromName, String columnToName) { + withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.MYSQL)) { + activeMysql(owner).copyColumnData(columnFromName, columnToName, DataType.STRING); + } else if (activeStorageType(owner).equals(UserStorage.SQLITE)) { + activeTable(owner).copyColumnData(columnFromName, columnToName, DataType.STRING); + } + return null; + }); + } + + public List getAllColumns() { + return withActiveStorageOwner(owner -> { + UserStorage storage = activeStorageType(owner); + if (storage.equals(UserStorage.SQLITE)) return activeTable(owner).getColumnsString(); + if (storage.equals(UserStorage.MYSQL)) return activeMysql(owner).getColumns(); + return new ArrayList<>(); + }); + } @Deprecated - public HashMap> getAllKeys() { - return getAllKeys(plugin.getStorageType()); - } - - public HashMap> getAllKeys(UserStorage storage) { - if (storage.equals(UserStorage.SQLITE)) { - return plugin.getSQLiteUserTable().getAllQuery(); - } - if (storage.equals(UserStorage.MYSQL)) { - return plugin.getMysql().getAllQuery(); + public HashMap> getAllKeys() { + return withActiveStorageOwner(owner -> getAllKeys(activeStorageType(owner), owner)); + } + + public HashMap> getAllKeys(UserStorage storage) { + return withActiveStorageOwner(owner -> getAllKeys(storage, owner)); + } + + private HashMap> getAllKeys(UserStorage storage, AdvancedCorePlugin.UserStorageOwner owner) { + owner = ownerFor(storage, owner); + if (storage.equals(UserStorage.SQLITE)) { + return activeTable(owner).getAllQuery(); + } + if (storage.equals(UserStorage.MYSQL)) { + return activeMysql(owner).getAllQuery(); } return new HashMap<>(); } - public ArrayList getAllPlayerNames() { - if (plugin.isLoadUserData()) { - ArrayList names = new ArrayList<>(); - if (AdvancedCorePlugin.getInstance().getStorageType().equals(UserStorage.SQLITE)) { - ArrayList data = plugin.getSQLiteUserTable().getNames(); + public ArrayList getAllPlayerNames() { + return withActiveStorageOwner(owner -> { + if (!plugin.isLoadUserData()) return new ArrayList<>(); + UserStorage storage = activeStorageType(owner); + ArrayList names = new ArrayList<>(); + if (storage.equals(UserStorage.SQLITE)) { + ArrayList data = activeTable(owner).getNames(); for (String name : data) { if (name != null && !name.isEmpty() && !name.equalsIgnoreCase("Error getting name")) { names.add(name); } } - } else if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - ArrayList data = ArrayUtils.convert(plugin.getMysql().getNames()); + } else if (storage.equals(UserStorage.MYSQL)) { + ArrayList data = ArrayUtils.convert(activeMysql(owner).getNames()); for (String name : data) { if (name != null && !name.isEmpty() && !name.equalsIgnoreCase("Error getting name")) { names.add(name); } } } - return ArrayUtils.removeDuplicates(names); - } - return new ArrayList<>(); + return ArrayUtils.removeDuplicates(names); + }); } /** @@ -113,38 +156,42 @@ public ArrayList getAllPlayerNames() { * @param perUser BiConsumer called per user with UUID and column list * @param onFinished Consumer called once after all users processed with total */ - public void forEachUserKeys(BiConsumer> perUser, Consumer onFinished) { - UserStorage storage = plugin.getStorageType(); - - if (storage == UserStorage.MYSQL) { - plugin.getMysql().forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { - if (onFinished != null) { - onFinished.accept(count); - } - }); - return; - } - - if (storage == UserStorage.SQLITE) { - plugin.getSQLiteUserTable().forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { - if (onFinished != null) { - onFinished.accept(count); - } - }); - return; - } - - throw new IllegalStateException("User storage is not configured"); + public void forEachUserKeys(BiConsumer> perUser, Consumer onFinished) { + withActiveStorageOwner(owner -> { + UserStorage storage = activeStorageType(owner); + if (storage == UserStorage.MYSQL) { + activeMysql(owner).forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { + if (onFinished != null) { + onFinished.accept(count); + } + }); + return null; + } + if (storage == UserStorage.SQLITE) { + activeTable(owner).forEachUser((uuid, cols) -> perUser.accept(uuid, cols), (count) -> { + if (onFinished != null) { + onFinished.accept(count); + } + }); + return null; + } + throw new IllegalStateException("User storage is not configured"); + }); } - public ArrayList getAllUUIDs() { - return ArrayUtils.removeDuplicates(getAllUUIDs(plugin.getStorageType())); - } - - public ArrayList getAllUUIDs(UserStorage storage) { - if (plugin.isLoadUserData()) { - if (storage.equals(UserStorage.SQLITE)) { - List cols = plugin.getSQLiteUserTable().getRows(); + public ArrayList getAllUUIDs() { + return withActiveStorageOwner(owner -> ArrayUtils.removeDuplicates(getAllUUIDs(activeStorageType(owner), owner))); + } + + public ArrayList getAllUUIDs(UserStorage storage) { + return withActiveStorageOwner(owner -> getAllUUIDs(storage, owner)); + } + + private ArrayList getAllUUIDs(UserStorage storage, AdvancedCorePlugin.UserStorageOwner owner) { + if (plugin.isLoadUserData()) { + owner = ownerFor(storage, owner); + if (storage.equals(UserStorage.SQLITE)) { + List cols = activeTable(owner).getRows(); ArrayList uuids = new ArrayList<>(); for (Column col : cols) { if (col.getValue().isString()) { @@ -156,7 +203,7 @@ public ArrayList getAllUUIDs(UserStorage storage) { synchronized (obj) { ArrayList uuids = new ArrayList<>(); try { - for (String uuid : plugin.getMysql().getUuids()) { + for (String uuid : activeMysql(owner).getUuids()) { uuids.add(uuid); } } catch (NullPointerException e) { @@ -169,14 +216,12 @@ public ArrayList getAllUUIDs(UserStorage storage) { return new ArrayList<>(); } - public ArrayList getNumbersInColumn(String columnName) { - if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - return plugin.getMysql().getNumbersInColumn(columnName); - } - if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - return plugin.getSQLiteUserTable().getNumbersInColumn(columnName); - } - return new ArrayList<>(); + public ArrayList getNumbersInColumn(String columnName) { + return withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.MYSQL)) return activeMysql(owner).getNumbersInColumn(columnName); + if (activeStorageType(owner).equals(UserStorage.SQLITE)) return activeTable(owner).getNumbersInColumn(columnName); + return new ArrayList<>(); + }); } public String getOfflineRewardsPath() { @@ -363,12 +408,12 @@ public void onStartUp(AdvancedCoreUser user) { getDataManager().clearCache(); } - public void removeAllKeyValues(String key, DataType type) { - if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - plugin.getSQLiteUserTable().wipeColumnData(key, type); - } else if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().wipeColumnData(key, type); - } + public void removeAllKeyValues(String key, DataType type) { + withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.SQLITE)) activeTable(owner).wipeColumnData(key, type); + else if (activeStorageType(owner).equals(UserStorage.MYSQL)) activeMysql(owner).wipeColumnData(key, type); + return null; + }); } public boolean userExistStored(String name) { @@ -390,12 +435,15 @@ public boolean userExistStored(String name) { return false; } - public void removeUUID(UUID key) { - if (plugin.getStorageType().equals(UserStorage.SQLITE)) { - plugin.getSQLiteUserTable().delete(new Column("uuid", new DataValueString(key.toString()))); - } else if (plugin.getStorageType().equals(UserStorage.MYSQL)) { - plugin.getMysql().deletePlayer(key.toString()); - } + public void removeUUID(UUID key) { + withActiveStorageOwner(owner -> { + if (activeStorageType(owner).equals(UserStorage.SQLITE)) { + activeTable(owner).delete(new Column("uuid", new DataValueString(key.toString()))); + } else if (activeStorageType(owner).equals(UserStorage.MYSQL)) { + activeMysql(owner).deletePlayer(key.toString()); + } + return null; + }); } public boolean userExist(String name) { diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java index 4cf8afced..0f2bb05f7 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 @@ -50,6 +50,9 @@ public class UserDataManager { private volatile Consumer sharedCacheRemovalListener; private volatile SharedSqlRoute sharedSqlRoute; private volatile SharedUserDataRuntime sharedRuntime; + /** A replacement is serialized with retirement so a native owner cannot be closed mid-flush. */ + private volatile CompletionStage sharedRuntimeReplacement; + private volatile boolean sharedRuntimeReplacing; /** True from retirement admission until its native-owner callback has finished. */ private volatile boolean sharedRuntimeRetiring; /** The one in-flight retirement, retained so lifecycle callers can await it safely. */ @@ -80,10 +83,12 @@ public final T withCacheMapReadAdmission(Supplier operation) { finally { cacheMapLifecycle.readLock().unlock(); } } - private record SharedSqlRoute(SqlUserBackend backend, BiConsumer gate, + private record SharedSqlRoute(SqlUserBackend backend, AdvancedCorePlugin.UserStorageOwner nativeOwner, + Consumer lifecycleGate, BiConsumer gate, BiConsumer exclusiveGate) { SharedSqlRoute { Objects.requireNonNull(backend, "backend"); + Objects.requireNonNull(lifecycleGate, "lifecycleGate"); Objects.requireNonNull(gate, "gate"); Objects.requireNonNull(exclusiveGate, "exclusiveGate"); } @@ -164,13 +169,25 @@ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiCo /** Publish the shared read and exclusive per-user lifecycle admissions together. */ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, BiConsumer gate, BiConsumer exclusiveGate) { - sharedSqlRoute = new SharedSqlRoute(backend, gate, exclusiveGate); + bindSharedSqlBackend(backend, operation -> operation.run(), gate, exclusiveGate); + } + + /** Publish native bulk admission with the per-user route in one immutable binding. */ + public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Consumer lifecycleGate, + BiConsumer gate, BiConsumer exclusiveGate) { + Objects.requireNonNull(lifecycleGate, "lifecycleGate"); + AdvancedCorePlugin.UserStorageOwner owner = plugin == null ? null : plugin.getNativeUserStorageOwner(); + if (owner != null && owner.storageType() != backend.storageType()) { + throw new IllegalStateException("Native user storage owner does not match the shared backend"); + } + sharedSqlRoute = new SharedSqlRoute(backend, owner, lifecycleGate, gate, exclusiveGate); } /** Compatibility overload for adapters that only need the global lifecycle gate. */ public final synchronized void bindSharedSqlBackend(SqlUserBackend backend, Consumer gate) { Objects.requireNonNull(gate, "gate"); - bindSharedSqlBackend(backend, (uuid, operation) -> gate.accept(operation)); + bindSharedSqlBackend(backend, gate, (uuid, operation) -> gate.accept(operation), + (uuid, operation) -> gate.accept(operation)); } public final synchronized void unbindSharedSqlBackend(SqlUserBackend expected) { @@ -192,6 +209,44 @@ public final UserStorage effectiveStorageType(UserStorage configured) { return route == null ? Objects.requireNonNull(configured, "configured") : route.backend().storageType(); } + /** + * The native owner captured with the current shared route. Public bulk APIs + * use this rather than separately reading a route type and mutable plugin + * owner field during an asynchronous replacement. + */ + public final AdvancedCorePlugin.UserStorageOwner sharedNativeUserStorageOwner() { + SharedSqlRoute route = sharedSqlRoute; + return route == null ? null : route.nativeOwner(); + } + + /** + * Resolve the native owner only after lifecycle read admission. A replacement + * takes the matching write admission before it can publish a route or close + * the previous provider. Main-thread callers fail instead of waiting on SQL. + */ + public final T withSharedNativeUserStorage( + java.util.function.Function operation) { + Objects.requireNonNull(operation, "operation"); + SharedSqlRoute admission = sharedSqlRoute; + if (admission == null || isStorageMaintenanceActive()) { + return operation.apply(admission == null + ? (plugin == null ? null : plugin.getNativeUserStorageOwner()) : admission.nativeOwner()); + } + if (Bukkit.getServer() != null && Bukkit.isPrimaryThread()) { + throw new IllegalStateException("Shared user storage must run on a worker thread"); + } + AtomicReference result = new AtomicReference<>(); + admission.lifecycleGate().accept(() -> { + SharedSqlRoute current = sharedSqlRoute; + if (current == null || current.lifecycleGate() != admission.lifecycleGate()) { + throw new IllegalStateException("Shared SQL lifecycle changed while waiting for bulk admission"); + } + if (current.nativeOwner() == null) throw new IllegalStateException("Shared native user storage is unavailable"); + result.set(operation.apply(current.nativeOwner())); + }); + return result.get(); + } + /** * Run an explicit storage-maintenance operation behind the shared runtime's * write barrier. Calls made by the operation may target a non-current store @@ -268,6 +323,68 @@ public final boolean hasSharedRuntime() { return runtime != null && !runtime.isClosed(); } + /** + * Replace the active shared backend on this manager's worker. The runtime + * flushes and retires the old cache generation before publishing the new + * route, so callers may safely prepare a replacement without blocking a + * Bukkit thread. A concurrent shutdown wins safely: the replacement then + * completes exceptionally rather than touching a retiring provider. + */ + public final CompletionStage replaceSharedSqlBackendAsync(SqlUserBackend replacement) { + return replaceSharedSqlBackendAsync(replacement, () -> {}); + } + + /** + * Asynchronously replace the backend and publish native-owner state before + * exposing completion to shutdown callers. The callback must be short and + * non-blocking; it runs on this manager's worker while the replacement route + * is still protected from a concurrent retirement admission. + */ + public final CompletionStage replaceSharedSqlBackendAsync(SqlUserBackend replacement, Runnable afterReplacement) { + Objects.requireNonNull(replacement, "replacement"); + Objects.requireNonNull(afterReplacement, "afterReplacement"); + SharedUserDataRuntime runtime; + CompletableFuture completion = new CompletableFuture<>(); + synchronized (this) { + runtime = sharedRuntime; + if (runtime == null || sharedRuntimeRetiring) { + completion.completeExceptionally(new IllegalStateException( + "Shared user storage is retiring and cannot be reloaded")); + return completion; + } + if (sharedRuntimeReplacement != null && !sharedRuntimeReplacement.toCompletableFuture().isDone()) { + completion.completeExceptionally(new IllegalStateException("Shared user storage reload is already in progress")); + return completion; + } + sharedRuntimeReplacement = completion; + sharedRuntimeReplacing = true; + } + try { + timer.execute(() -> { + Throwable replacementFailure = null; + try { + runtime.replaceBackend(replacement, afterReplacement); + } catch (Throwable failure) { + replacementFailure = failure; + } finally { + synchronized (UserDataManager.this) { + if (sharedRuntimeReplacement == completion) sharedRuntimeReplacement = null; + sharedRuntimeReplacing = false; + } + } + if (replacementFailure == null) completion.complete(null); + else completion.completeExceptionally(replacementFailure); + }); + } catch (RuntimeException | Error failure) { + completion.completeExceptionally(failure); + synchronized (this) { + if (sharedRuntimeReplacement == completion) sharedRuntimeReplacement = null; + sharedRuntimeReplacing = false; + } + } + return completion; + } + /** * A native storage replacement must not race the shared runtime's final flush * and owner cleanup. This remains true while asynchronous retirement is in @@ -299,6 +416,10 @@ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable af SharedUserDataRuntime runtime; CompletableFuture completion; synchronized (this) { + if (sharedRuntimeReplacing) { + CompletionStage replacement = sharedRuntimeReplacement; + return replacement.thenCompose(ignored -> closeSharedRuntimeAsyncCompletion(afterRetirement)); + } runtime = sharedRuntime; // A second shutdown caller must not interpret an in-flight retirement as // "no runtime" and close the native provider underneath its final flush. 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 906a26802..052c1cfe1 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 @@ -81,7 +81,7 @@ public synchronized void bindLifecycle(SqlUserBackend backend, Consumer perUser = userGate; - if (perUser != null) manager.bindSharedSqlBackend(backend, perUser, + if (perUser != null) manager.bindSharedSqlBackend(backend, flushGate, perUser, exclusiveUserGate == null ? perUser : exclusiveUserGate); else if (flushGate != null) manager.bindSharedSqlBackend(backend, flushGate); } @@ -253,6 +253,8 @@ private record CachePopulation(UUID uuid, UserDataCache cache, long version) imp for (UUID uuid : Set.copyOf(pendingNotifications.keySet())) dispatchNotifications(uuid); } + @Override public void discardAllNotifications() { pendingNotifications.clear(); } + @Override public Set cachedUsers() { return new HashSet<>(manager.getUserDataCache().keySet()); } @Override public void beginRemoval(UUID uuid) { 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 d9cb61338..1b845cad6 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 @@ -26,16 +26,38 @@ public final class BukkitSqlUserBackend implements SqlUserBackend { private final AdvancedCorePlugin plugin; private final UserStorage storageType; + private final MySQL mysql; + private final UserTable table; private final Object sqliteOperations = new Object(); private final AtomicBoolean open = new AtomicBoolean(true); public BukkitSqlUserBackend(AdvancedCorePlugin plugin) { this.plugin = Objects.requireNonNull(plugin, "plugin"); this.storageType = Objects.requireNonNull(plugin.getStorageType(), "storageType"); - if (storageType() == UserStorage.MYSQL) requireMysql(); - else requireTable(); + if (storageType() == UserStorage.MYSQL) { + this.mysql = plugin.getMysql(); + this.table = null; + if (mysql == null) throw new IllegalStateException("Bukkit MySQL user storage is unavailable"); + } else { + this.mysql = null; + this.table = plugin.getSQLiteUserTable(); + if (table == null) throw new IllegalStateException("Bukkit SQLite user storage is unavailable"); + } } + /** + * Bind a replacement runtime to native storage prepared off-thread. The + * backend deliberately retains these exact owners instead of resolving the + * plugin's mutable fields on each operation, so a configuration reload cannot + * redirect an old flush to a newly installed provider. + */ + public BukkitSqlUserBackend(AdvancedCorePlugin plugin, UserStorage storageType, MySQL mysql, UserTable table) { + this.plugin = Objects.requireNonNull(plugin, "plugin"); + this.storageType = Objects.requireNonNull(storageType, "storageType"); + this.mysql = storageType == UserStorage.MYSQL ? Objects.requireNonNull(mysql, "mysql") : null; + this.table = storageType == UserStorage.SQLITE ? Objects.requireNonNull(table, "table") : null; + } + @Override public UserStorage storageType() { return storageType; } @Override @@ -140,15 +162,13 @@ private void writeValues(UserStorage storage, UUID uuid, HashMap lifecycleGate = batch -> access(() -> { batch.run(); return null; }); + Consumer lifecycleGate = batch -> storageAccess(() -> { batch.run(); return null; }); BiConsumer perUserGate = (uuid, batch) -> userAccess(uuid, () -> { batch.run(); return null; }); BiConsumer exclusiveUserGate = (uuid, batch) -> userExclusiveAccess(uuid, () -> { batch.run(); return null; }); cacheOwner.bindLifecycle(backend, lifecycleGate, perUserGate, exclusiveUserGate); @@ -158,9 +158,15 @@ public void runStorageMaintenance(Runnable operation) { private void flushAllInternal() { for (UUID uuid : Set.copyOf(cacheOwner.cachedUsers())) flushInternal(uuid); } public void replaceBackend(SqlUserBackend replacement) { + replaceBackend(replacement, () -> {}); + } + + /** Replace the route and publish its platform owner before releasing lifecycle admission. */ + public void replaceBackend(SqlUserBackend replacement, Runnable afterReplacement) { rejectReentrantTransition(); cacheOwner.requireBlockingAllowed(); Objects.requireNonNull(replacement, "replacement"); + Objects.requireNonNull(afterReplacement, "afterReplacement"); lifecycle.writeLock().lock(); try { requireOpen(); @@ -172,12 +178,18 @@ public void replaceBackend(SqlUserBackend replacement) { flushAllInternal(); cacheOwner.clearAfterFlush(); SqlUserBackend previous = backend; + // Publish the native owner before its route. The cache owner captures the + // owner snapshot together with the new route, so public bulk APIs never + // combine a new route type with the old mutable provider fields. + afterReplacement.run(); cacheOwner.bindBackend(replacement); backend = replacement; try { previous.close(); } catch (RuntimeException | Error failure) { + // The replacement is already published and owns the active route. + // Retain the old backend for a later close retry, but never report this + // as a failed replacement: callers must not tear down the live owner. pendingBackendClose = previous; - throw failure; } } catch (RuntimeException | Error failure) { cacheOwner.cancelRetirement(); @@ -219,6 +231,12 @@ public void remove(UUID uuid) { public boolean isClosed() { return closed; } public boolean isRetiring() { return retiring.get(); } + /** Admit a native bulk operation for the life of its provider access. */ + public T withStorageReadAdmission(Supplier operation) { + Objects.requireNonNull(operation, "operation"); + return storageAccess(operation); + } + public CompletionStage closeAsync(Executor executor) { Objects.requireNonNull(executor, "executor"); rejectReentrantTransition(); @@ -231,6 +249,7 @@ public CompletionStage closeAsync(Executor executor) { } try { executor.execute(() -> { + boolean terminallyClosed = false; try { cacheOwner.requireBlockingAllowed(); lifecycle.writeLock().lock(); @@ -243,10 +262,12 @@ public CompletionStage closeAsync(Executor executor) { retryPendingBackendClose(); backend.close(); closed = true; + terminallyClosed = true; } } finally { lifecycle.writeLock().unlock(); - cacheOwner.dispatchAllNotifications(); + if (terminallyClosed) cacheOwner.discardAllNotifications(); + else cacheOwner.dispatchAllNotifications(); } result.complete(null); } catch (Throwable failure) { result.completeExceptionally(failure); } 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 d0b83c691..81038cf0b 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 @@ -62,6 +62,9 @@ default void dispatchNotifications(UUID uuid) {} /** Deliver callbacks accumulated by a lifecycle-wide flush after all admission is released. */ default void dispatchAllNotifications() {} + /** Discard callbacks after terminal shutdown; unloaded consumers must not be invoked. */ + default void discardAllNotifications() {} + Set cachedUsers(); /** Fence direct cache publishers before an exclusive delete begins. */ default void beginRemoval(UUID uuid) {} 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 f50c72b63..c7e2e4d3a 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 @@ -585,6 +585,44 @@ void rewardPreparationRunsOnPlayerOwnerScheduler() { assertEquals("reward-owner-thread", playerAccessThread.get().getName()); } + @Test + void asyncRewardSnapshotsCallerPlaceholdersBeforeOwnerSchedulerHandoff() { + Player player = mock(Player.class); + when(user.getPlayer()).thenReturn(player); + when(user.getPlayerName()).thenReturn("Queued"); + when(user.getUUID()).thenReturn(UUID.randomUUID().toString()); + when(player.getDisplayName()).thenReturn("Display"); + ArrayList queued = new ArrayList<>(); + doAnswer(invocation -> { + queued.add(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).executeOrScheduleSync(eq(plugin), any(Runnable.class)); + AtomicReference observed = new AtomicReference<>(); + handler.getInjectedRewards().add(new RewardInject("Async") { + @Override public boolean supportsAsyncRequest() { return true; } + @Override public Object onRewardRequest(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { return null; } + @Override public CompletionStage onRewardRequestAsync(Reward ignored, AdvancedCoreUser ignoredUser, + ConfigurationSection ignoredData, HashMap ignoredPlaceholders) { + observed.set(ignoredPlaceholders.get("custom")); + return CompletableFuture.completedFuture(null); + } + }); + HashMap callerPlaceholders = new HashMap<>(); + callerPlaceholders.put("custom", "original"); + + CompletionStage delivery = reward.giveRewardUserAsync(user, callerPlaceholders, new RewardOptions()); + callerPlaceholders.clear(); + callerPlaceholders.put("custom", "mutated"); + assertFalse(delivery.toCompletableFuture().isDone()); + assertEquals(1, queued.size()); + + while (!delivery.toCompletableFuture().isDone() && !queued.isEmpty()) queued.remove(0).run(); + + delivery.toCompletableFuture().join(); + assertEquals("original", observed.get()); + } + @Test void offlineRequeueRetainsReplayStateAndLegacyActionMarkers() { AdvancedCoreConfigOptions config = mock(AdvancedCoreConfigOptions.class); 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 813714b01..bf7f2eb14 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 @@ -109,6 +109,7 @@ class SharedCacheBindingRegressionTest { @Test void legacyCachePopulationPublishesOnlyAfterSharedAdmission() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); manager.getTimer().shutdownNow(); UUID uuid = UUID.randomUUID(); @@ -144,6 +145,7 @@ class SharedCacheBindingRegressionTest { @Test void preBindingCachePopulationBlocksSharedTransitionAdmission() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); manager.getTimer().shutdownNow(); UUID uuid = UUID.randomUUID(); @@ -852,6 +854,7 @@ private void cacheMiss(UserDataFetchMode mode) { @Test void failedManagerWideClearReopensEveryStillMappedCache() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); manager.getTimer().shutdownNow(); try { @@ -876,6 +879,7 @@ private void cacheMiss(UserDataFetchMode mode) { @Test void failedPerUserRemovalReopensTheMappedCache() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); manager.getTimer().shutdownNow(); try { @@ -938,6 +942,7 @@ private static final class Fixture implements AutoCloseable { final ScheduledExecutorService timer = mock(ScheduledExecutorService.class); Fixture() throws Exception { + when(plugin.getNativeUserStorageOwner()).thenReturn(null); when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); manager = spy(new UserDataManager(plugin)); manager.getTimer().shutdownNow(); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index fab9b5d7b..93eafb492 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -39,6 +39,7 @@ import org.mockito.ArgumentCaptor; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.misc.PlayerManager; import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserData; import com.bencodez.advancedcore.api.user.UserManager; @@ -50,6 +51,44 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; class SharedCacheCleanupPrimaryThreadTest { + @Test + void uuidUserConstructionDefersNameLookupOnPrimaryThread() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + when(plugin.getUserManager()).thenReturn(users); + UserDataManager manager = new UserDataManager(plugin); + when(users.getDataManager()).thenReturn(manager); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (user, operation) -> operation.run()); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + UUID uuid = UUID.randomUUID(); + try (var bukkit = mockStatic(Bukkit.class); var players = mockStatic(PlayerManager.class)) { + bukkit.when(Bukkit::getServer).thenReturn(mock(Server.class)); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true, false); + PlayerManager playerManager = mock(PlayerManager.class); + players.when(PlayerManager::getInstance).thenReturn(playerManager); + when(playerManager.getPlayerName(any(AdvancedCoreUser.class), eq(uuid.toString()), eq(false))) + .thenReturn("StoredName"); + + AdvancedCoreUser user = assertDoesNotThrow(() -> new AdvancedCoreUser(plugin, uuid)); + assertEquals("", user.getPlayerName()); + verify(playerManager, never()).getPlayerName(any(), any(), eq(false)); + ArgumentCaptor storageTask = ArgumentCaptor.forClass(Runnable.class); + verify(worker).execute(storageTask.capture()); + storageTask.getValue().run(); + ArgumentCaptor callback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), callback.capture()); + callback.getValue().run(); + assertEquals("StoredName", user.getPlayerName()); + } + } + @Test void primaryThreadPersistedBulkReadsRequireWorkerDeferral() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); @@ -226,6 +265,7 @@ void acceptedPrimaryThreadCleanupFailureIsLoggedAndRetained() throws Exception { @Test void failedPrimaryThreadPopulationIsRetainedBeforeNotificationDelivery() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); manager.getTimer().shutdownNow(); ScheduledExecutorService worker = mock(ScheduledExecutorService.class); @@ -255,6 +295,7 @@ void failedPrimaryThreadPopulationIsRetainedBeforeNotificationDelivery() throws @Test void synchronousPopulationRethrowsStorageFailure() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); IllegalStateException failure = new IllegalStateException("storage unavailable"); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java index 8219e9184..8af6786a6 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedRouteReplacementRaceTest.java @@ -72,6 +72,7 @@ void legacyOperationResolvesTheBackendAfterLifecycleAdmission() throws Exception @Test void detachedCacheWriterFollowsBackendReplacement() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.getNativeUserStorageOwner()).thenReturn(null); UserDataManager manager = new UserDataManager(plugin); UUID uuid = UUID.randomUUID(); BukkitUserCacheOwner owner = new BukkitUserCacheOwner(manager); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java index b93306856..146f8d057 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserDataRuntimeTest.java @@ -1,10 +1,16 @@ package com.bencodez.advancedcore.tests.user; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import java.util.ArrayList; import java.util.HashMap; @@ -109,6 +115,49 @@ void queuedChangesFlushBeforeShutdownAndBackendReplacement() { assertTrue(runtime.isClosed()); } + @Test + void failedOldBackendCloseDoesNotTearDownThePublishedReplacement() { + SqlUserBackend old = mock(SqlUserBackend.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + SqlUserBackend laterReplacement = mock(SqlUserBackend.class); + when(old.isOpen()).thenReturn(true); + when(replacement.isOpen()).thenReturn(true); + when(laterReplacement.isOpen()).thenReturn(true); + when(old.storageType()).thenReturn(UserStorage.SQLITE); + when(replacement.storageType()).thenReturn(UserStorage.MYSQL); + when(laterReplacement.storageType()).thenReturn(UserStorage.SQLITE); + java.util.concurrent.atomic.AtomicBoolean firstClose = new java.util.concurrent.atomic.AtomicBoolean(true); + doAnswer(ignored -> { + if (firstClose.getAndSet(false)) throw new IllegalStateException("old close failed"); + return null; + }).when(old).close(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(old, new FakeCacheOwner()); + + assertDoesNotThrow(() -> runtime.replaceBackend(replacement)); + assertSame(replacement, runtime.backend()); + assertDoesNotThrow(() -> runtime.replaceBackend(laterReplacement)); + assertSame(laterReplacement, runtime.backend()); + verify(old, times(2)).close(); + } + + @Test + void successfulShutdownDiscardsFinalFlushNotifications() { + UUID uuid = UUID.randomUUID(); + FakeBackend backend = new FakeBackend(); + backend.put(uuid, "Points", new DataValueInt(1)); + FakeCacheOwner cache = new FakeCacheOwner(); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(backend, cache); + runtime.queueChange(uuid, "Points", new DataValueInt(2)); + boolean[] notified = { false }; + cache.notifyAfterFlush(uuid, () -> notified[0] = true); + + runtime.close(); + + assertEquals(2, backend.value(uuid, "Points").getInt()); + assertFalse(notified[0]); + assertTrue(cache.notifications.isEmpty()); + } + @Test void failedFlushDoesNotDiscardQueueOrCloseBackend() { UUID uuid = UUID.randomUUID(); @@ -213,6 +262,9 @@ public void dispatchAllNotifications() { for (UUID uuid : Set.copyOf(notifications.keySet())) dispatchNotifications(uuid); } + @Override + public void discardAllNotifications() { notifications.clear(); } + @Override public Set cachedUsers() { return new HashSet<>(cache.keySet()); 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 944836016..b039cc870 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 @@ -1,9 +1,11 @@ package com.bencodez.advancedcore.tests.user; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doNothing; @@ -15,19 +17,168 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.function.BiConsumer; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.AdvancedCoreConfigOptions; import com.bencodez.advancedcore.api.user.UserManager; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.core.user.runtime.SharedUserDataRuntime; +import com.bencodez.advancedcore.core.user.runtime.UserCacheOwner; +import com.bencodez.advancedcore.core.user.storage.SqlUserStorage; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.sql.data.DataValue; class SharedUserStorageReloadSafetyTest { + @Test + void sharedStorageReloadExposesAnIncompleteCompletionStageUntilReplacementFinishes() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + UserManager users = mock(UserManager.class); + UserDataManager manager = mock(UserDataManager.class); + when(manager.hasSharedRuntime()).thenReturn(true); + when(users.getDataManager()).thenReturn(manager); + when(plugin.getLoadedUserManager()).thenReturn(users); + var scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + + CompletableFuture completion = plugin.reloadAdvancedCoreAsync(true).toCompletableFuture(); + + verify(scheduler).runTask(eq(plugin), any(Runnable.class)); + verify(plugin, never()).getServerDataFile(); + assertFalse(completion.isDone()); + } + + @Test + void publicStorageTypeRemainsPinnedToTheActiveSharedBackendAfterConfigReload() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(options.getStorageType()).thenReturn(UserStorage.SQLITE); + when(plugin.getOptions()).thenReturn(options); + UserManager users = mock(UserManager.class); + UserDataManager manager = new UserDataManager(plugin); + when(plugin.getLoadedUserManager()).thenReturn(users); + when(users.getDataManager()).thenReturn(manager); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.storageType()).thenReturn(UserStorage.MYSQL); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + try { + assertEquals(UserStorage.MYSQL, plugin.getStorageType()); + } finally { + manager.getTimer().shutdownNow(); + } + } + + @Test + void bulkApisUseTheNativeOwnerCapturedWithTheSharedRoute() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL replacementOwner = mock(MySQL.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + when(replacement.storageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getNativeUserStorageOwner()).thenReturn( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, replacementOwner, null)); + // Simulate the interval in a cross-type replacement where a caller could + // otherwise observe a new route type and an unrelated legacy field. + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + when(replacementOwner.getColumns()).thenReturn(List.of("uuid", "PlayerName")); + AdvancedCorePlugin.setInstance(plugin); + UserManager users = new UserManager(plugin); + try { + users.getDataManager().bindSharedSqlBackend(replacement, (uuid, operation) -> operation.run()); + + assertEquals(List.of("uuid", "PlayerName"), users.getAllColumns()); + verify(plugin, never()).getSQLiteUserTable(); + } finally { + users.getDataManager().getTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + } + } + + @Test + void bulkOperationHoldsLifecycleAdmissionUntilTheOldProviderIsNoLongerInUse() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + MySQL oldMysql = mock(MySQL.class); + MySQL replacementMysql = mock(MySQL.class); + SqlUserBackend oldBackend = mock(SqlUserBackend.class); + SqlUserBackend replacementBackend = mock(SqlUserBackend.class); + when(oldBackend.storageType()).thenReturn(UserStorage.MYSQL); + when(oldBackend.isOpen()).thenReturn(true); + when(replacementBackend.storageType()).thenReturn(UserStorage.MYSQL); + when(replacementBackend.isOpen()).thenReturn(true); + AtomicReference owner = new AtomicReference<>( + new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, oldMysql, null)); + when(plugin.getNativeUserStorageOwner()).thenAnswer(ignored -> owner.get()); + CountDownLatch bulkEntered = new CountDownLatch(1); + CountDownLatch releaseBulk = new CountDownLatch(1); + CountDownLatch oldClosed = new CountDownLatch(1); + when(oldMysql.getColumns()).thenAnswer(ignored -> { + bulkEntered.countDown(); + assertTrue(releaseBulk.await(5, TimeUnit.SECONDS)); + return List.of("uuid"); + }); + doAnswer(ignored -> { oldClosed.countDown(); return null; }).when(oldBackend).close(); + + AdvancedCorePlugin.setInstance(plugin); + UserManager users = new UserManager(plugin); + RoutingCacheOwner cacheOwner = new RoutingCacheOwner(users.getDataManager()); + SharedUserDataRuntime runtime = new SharedUserDataRuntime(oldBackend, cacheOwner); + users.getDataManager().bindSharedRuntime(runtime); + AtomicReference> result = new AtomicReference<>(); + AtomicReference failure = new AtomicReference<>(); + Thread bulk = new Thread(() -> { + try { result.set(users.getAllColumns()); } + catch (Throwable thrown) { failure.set(thrown); } + }, "bulk-owner-admission"); + Thread replacement = new Thread(() -> runtime.replaceBackend(replacementBackend, + () -> owner.set(new AdvancedCorePlugin.UserStorageOwner(UserStorage.MYSQL, replacementMysql, null))), + "replace-owner-admission"); + try { + bulk.start(); + assertTrue(bulkEntered.await(5, TimeUnit.SECONDS)); + replacement.start(); + assertFalse(oldClosed.await(200, TimeUnit.MILLISECONDS)); + releaseBulk.countDown(); + bulk.join(5000); + replacement.join(5000); + assertFalse(bulk.isAlive()); + assertFalse(replacement.isAlive()); + assertEquals(List.of("uuid"), result.get()); + assertEquals(null, failure.get()); + assertTrue(oldClosed.await(1, TimeUnit.SECONDS)); + } finally { + releaseBulk.countDown(); + users.getDataManager().getTimer().shutdownNow(); + AdvancedCorePlugin.setInstance(null); + } + } + + @Test + void sharedBackendReplacementCompletesOnTheManagerWorkerWithoutBlockingTheCaller() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + when(runtime.isClosed()).thenReturn(false); + manager.bindSharedRuntime(runtime); + try { + manager.replaceSharedSqlBackendAsync(replacement).toCompletableFuture().get(5, TimeUnit.SECONDS); + verify(runtime).replaceBackend(eq(replacement), any(Runnable.class)); + } finally { + manager.getTimer().shutdownNow(); + } + } + @Test - void loadAndReloadRejectBeforeAnyNativeStorageMutationWhenSharedRuntimeOwnsStorage() { + void directNativeStorageMutationRejectsWhileSharedRuntimeOwnsStorage() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS); UserManager users = mock(UserManager.class); UserDataManager manager = new UserDataManager(plugin); @@ -38,9 +189,7 @@ void loadAndReloadRejectBeforeAnyNativeStorageMutationWhenSharedRuntimeOwnsStora when(users.getDataManager()).thenReturn(manager); try { assertThrows(IllegalStateException.class, () -> plugin.loadUserAPI(UserStorage.MYSQL)); - assertThrows(IllegalStateException.class, () -> plugin.reloadAdvancedCore(true)); verify(plugin, never()).getOptions(); - verify(plugin, never()).getServerDataFile(); } finally { manager.getTimer().shutdownNow(); } @@ -96,4 +245,35 @@ void conversionRunsBehindTheRuntimeMaintenanceBarrierInsteadOfTheReloadGuard() { manager.getTimer().shutdownNow(); } } + + private static final class RoutingCacheOwner implements UserCacheOwner { + private final UserDataManager manager; + private Consumer lifecycleGate; + private BiConsumer userGate; + private BiConsumer exclusiveUserGate; + + private RoutingCacheOwner(UserDataManager manager) { this.manager = manager; } + + @Override public void bindLifecycle(SqlUserBackend backend, Consumer gate, + BiConsumer perUserGate, BiConsumer perUserExclusiveGate) { + lifecycleGate = gate; + userGate = perUserGate; + exclusiveUserGate = perUserExclusiveGate; + manager.bindSharedSqlBackend(backend, gate, perUserGate, perUserExclusiveGate); + } + + @Override public void bindBackend(SqlUserBackend backend) { + manager.bindSharedSqlBackend(backend, lifecycleGate, userGate, exclusiveUserGate); + } + + @Override public boolean isCached(UUID uuid) { return false; } + @Override public DataValue getIfPresent(UUID uuid, String key) { return null; } + @Override public void populate(UUID uuid, HashMap values) {} + @Override public void queueChange(UUID uuid, String key, DataValue value) {} + @Override public void flush(UUID uuid, SqlUserStorage storage) {} + @Override public Set cachedUsers() { return Set.of(); } + @Override public void remove(UUID uuid) {} + @Override public void clearAfterFlush() {} + @Override public void shutdown() {} + } } From 773a6b01c2709b5106682ede840ebe5c5a4bdab8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 05:46:34 -0600 Subject: [PATCH 33/34] Keep user managers bound to their plugin --- .../com/bencodez/advancedcore/api/user/UserManager.java | 8 ++++---- .../tests/user/SharedUserStorageReloadSafetyTest.java | 4 ---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java index cab7c07a7..cedc27f92 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/UserManager.java @@ -335,10 +335,10 @@ public AdvancedCoreUser getUser(UUID uuid, String playerName) { return new AdvancedCoreUser(plugin, uuid, playerName); } - public void load() { - dataManager = new UserDataManager(AdvancedCorePlugin.getInstance()); - validationService = UserValidationFactory.create(plugin); - } + public void load() { + dataManager = new UserDataManager(plugin); + validationService = UserValidationFactory.create(plugin); + } public void onChange(AdvancedCoreUser user, String... keys) { for (UserDataChanged change : userDataChange) { 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 b039cc870..1b4db7d9a 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 @@ -90,7 +90,6 @@ void bulkApisUseTheNativeOwnerCapturedWithTheSharedRoute() { // otherwise observe a new route type and an unrelated legacy field. when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(replacementOwner.getColumns()).thenReturn(List.of("uuid", "PlayerName")); - AdvancedCorePlugin.setInstance(plugin); UserManager users = new UserManager(plugin); try { users.getDataManager().bindSharedSqlBackend(replacement, (uuid, operation) -> operation.run()); @@ -99,7 +98,6 @@ void bulkApisUseTheNativeOwnerCapturedWithTheSharedRoute() { verify(plugin, never()).getSQLiteUserTable(); } finally { users.getDataManager().getTimer().shutdownNow(); - AdvancedCorePlugin.setInstance(null); } } @@ -127,7 +125,6 @@ void bulkOperationHoldsLifecycleAdmissionUntilTheOldProviderIsNoLongerInUse() th }); doAnswer(ignored -> { oldClosed.countDown(); return null; }).when(oldBackend).close(); - AdvancedCorePlugin.setInstance(plugin); UserManager users = new UserManager(plugin); RoutingCacheOwner cacheOwner = new RoutingCacheOwner(users.getDataManager()); SharedUserDataRuntime runtime = new SharedUserDataRuntime(oldBackend, cacheOwner); @@ -157,7 +154,6 @@ void bulkOperationHoldsLifecycleAdmissionUntilTheOldProviderIsNoLongerInUse() th } finally { releaseBulk.countDown(); users.getDataManager().getTimer().shutdownNow(); - AdvancedCorePlugin.setInstance(null); } } From 07fdd86b921eb05129a0df9cb721ad3c5321e4f5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 06:23:12 -0600 Subject: [PATCH 34/34] Defer shared storage administration safely --- .../advancedcore/AdvancedCorePlugin.java | 128 +++++++++++-- .../api/user/usercache/UserDataManager.java | 25 ++- .../advancedcore/command/CommandLoader.java | 144 ++++++++------ ...ancedCoreUuidTabCompletionRefreshTest.java | 78 ++++++++ .../CommandLoaderBulkPermissionTest.java | 181 ++++++++++++++++++ .../SharedUserStorageReloadSafetyTest.java | 38 ++++ 6 files changed, 511 insertions(+), 83 deletions(-) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java index cedc1ce57..d665c354e 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/AdvancedCorePlugin.java @@ -208,6 +208,12 @@ public record UserStorageOwner(UserStorage storageType, MySQL mysql, UserTable t /** Coalesces public storage reload requests while a replacement is being prepared. */ private Object userStorageReloadLock = new Object(); private CompletionStage userStorageReload; + /** + * Storage-backed tab completion cannot enumerate the user provider on the + * Bukkit thread during a shared-storage replacement. Keep at most one + * enumeration in flight and apply only the newest completed snapshot. + */ + private final UuidTabCompletionRefresh uuidTabCompletionRefresh = new UuidTabCompletionRefresh(); /** * Handler for full inventory management. @@ -999,26 +1005,7 @@ public void updateReplacements() { @Override public void reload() { - LinkedHashSet uuids = new LinkedHashSet<>(); - - // UUIDs from storage - for (String uuid : getUserManager().getAllUUIDs()) { - if (uuid != null && !uuid.isEmpty()) { - uuids.add(uuid); - } - } - - // Also include online players UUIDs depending on mode - for (Player player : Bukkit.getOnlinePlayers()) { - String uuid = getOptions().isOnlineMode() ? player.getUniqueId().toString() - : UuidLookup.getInstance().getUUID(player.getName()); // name-derived in offline-mode - - if (uuid != null && !uuid.isEmpty()) { - uuids.add(uuid); - } - } - - setReplace(new ArrayList<>(uuids)); + uuidTabCompletionRefresh.request(AdvancedCorePlugin.this, this); } @Override @@ -1185,6 +1172,107 @@ public void onStartUp(AdvancedCoreUser user) { TabCompleteHandler.getInstance().reload(); TabCompleteHandler.getInstance().loadTabCompleteOptions(); TabCompleteHandler.getInstance().loadTimer(getTimer()); + } + + /** + * Coalesces the storage-backed UUID replacement refresh so a reload never + * enumerates storage from the Bukkit thread. Bukkit player access and the + * replacement mutation remain on the global scheduler. A newer request wins + * over an older worker result. + */ + static final class UuidTabCompletionRefresh { + private final Object lock = new Object(); + private long nextGeneration; + private RefreshRequest current; + private boolean refreshInProgress; + + void request(AdvancedCorePlugin plugin, TabCompleteHandle handle) { + RefreshRequest refresh; + boolean schedule = false; + synchronized (lock) { + refresh = new RefreshRequest(++nextGeneration, handle); + current = refresh; + if (!refreshInProgress) { + refreshInProgress = true; + schedule = true; + } + } + if (schedule) schedule(plugin, refresh); + } + + private void schedule(AdvancedCorePlugin plugin, RefreshRequest refresh) { + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> enumerateStorage(plugin, refresh)); + } catch (Throwable failure) { + finish(plugin, refresh); + plugin.debug(failure); + } + } + + private void enumerateStorage(AdvancedCorePlugin plugin, RefreshRequest refresh) { + ArrayList storageUuids = new ArrayList<>(); + Throwable failure = null; + try { + for (String uuid : plugin.getUserManager().getAllUUIDs()) { + if (uuid != null && !uuid.isEmpty()) storageUuids.add(uuid); + } + } catch (Throwable caught) { + failure = caught; + } + final Throwable refreshFailure = failure; + try { + plugin.getBukkitScheduler().runTask(plugin, + () -> applyOnGlobalScheduler(plugin, refresh, storageUuids, refreshFailure)); + } catch (Throwable schedulingFailure) { + finish(plugin, refresh); + plugin.debug(schedulingFailure); + } + } + + private void applyOnGlobalScheduler(AdvancedCorePlugin plugin, RefreshRequest refresh, + ArrayList storageUuids, Throwable failure) { + try { + if (!isCurrent(refresh)) return; + if (failure != null) { + plugin.debug(failure); + return; + } + + LinkedHashSet uuids = new LinkedHashSet<>(storageUuids); + for (Player player : Bukkit.getOnlinePlayers()) { + String uuid = plugin.getOptions().isOnlineMode() ? player.getUniqueId().toString() + : UuidLookup.getInstance().getUUID(player.getName()); + if (uuid != null && !uuid.isEmpty()) uuids.add(uuid); + } + + ArrayList replacements = new ArrayList<>(uuids); + refresh.handle().setReplace(replacements); + TabCompleteHandler.getInstance().getTabCompleteOptions().put(refresh.handle().getToReplace(), replacements); + } finally { + finish(plugin, refresh); + } + } + + private boolean isCurrent(RefreshRequest refresh) { + synchronized (lock) { + return refresh.equals(current); + } + } + + private void finish(AdvancedCorePlugin plugin, RefreshRequest completed) { + RefreshRequest next = null; + synchronized (lock) { + if (!completed.equals(current)) { + next = current; + } else { + refreshInProgress = false; + } + } + if (next != null) schedule(plugin, next); + } + + private record RefreshRequest(long generation, TabCompleteHandle handle) { + } } /** 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 0f2bb05f7..da3d89329 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 @@ -418,7 +418,12 @@ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable af synchronized (this) { if (sharedRuntimeReplacing) { CompletionStage replacement = sharedRuntimeReplacement; - return replacement.thenCompose(ignored -> closeSharedRuntimeAsyncCompletion(afterRetirement)); + // A failed replacement leaves the old runtime and native owner alive. + // Shutdown must still retire that old owner; thenCompose would skip the + // recursive retirement entirely on the exceptional path. + return replacement.handle((ignored, replacementFailure) -> replacementFailure) + .thenCompose(replacementFailure -> continueRetirementAfterReplacement(afterRetirement, + replacementFailure)); } runtime = sharedRuntime; // A second shutdown caller must not interpret an in-flight retirement as @@ -451,6 +456,24 @@ public final CompletionStage closeSharedRuntimeAsyncCompletion(Runnable af return completion; } + private CompletionStage continueRetirementAfterReplacement(Runnable afterRetirement, + Throwable replacementFailure) { + if (replacementFailure != null) reportDeferredStorageFailure(replacementFailure); + CompletionStage retirement = closeSharedRuntimeAsyncCompletion(afterRetirement); + if (retirement == null) { + if (replacementFailure == null) return CompletableFuture.completedFuture(null); + return CompletableFuture.failedFuture(replacementFailure); + } + return retirement.handle((ignored, retirementFailure) -> { + if (replacementFailure != null) { + if (retirementFailure != null) replacementFailure.addSuppressed(retirementFailure); + throw new java.util.concurrent.CompletionException(replacementFailure); + } + if (retirementFailure != null) throw new java.util.concurrent.CompletionException(retirementFailure); + return null; + }); + } + /** * Route one complete legacy SQL operation through lifecycle admission. The gate * is captured only to enter the current runtime; the backend is resolved after diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java index da3bbe605..93cfff113 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/command/CommandLoader.java @@ -99,20 +99,16 @@ public void execute(CommandSender sender, String[] args) { } final String cmd = str; - // Stream instead of building a huge users list - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + // Stream instead of building a huge users list. Shared storage rejects + // primary-thread reads, so enumeration stays on the worker. + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - Bukkit.getServer().dispatchCommand(sender, - PlaceholderUtils.replacePlaceHolder(cmd, "player", user.getPlayerName())); - } - }); - }, null); + user.updateTempCacheWithColumns(columns); + String playerName = user.getPlayerName(); + plugin.getBukkitScheduler().runTask(plugin, () -> Bukkit.getServer().dispatchCommand(sender, + PlaceholderUtils.replacePlaceHolder(cmd, "player", playerName))); + }, null), null); } }); @@ -170,7 +166,10 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - sendMessage(sender, "Total number of users: " + plugin.getUserManager().getAllUUIDs().size()); + runUserStorageCommand(sender, () -> { + int count = plugin.getUserManager().getAllUUIDs().size(); + runCommandCallback(sender, () -> sendMessage(sender, "Total number of users: " + count)); + }, null); } }); @@ -181,21 +180,13 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { Reward reward = plugin.getRewardHandler().getReward(args[1]); - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - // safest: many reward actions touch Bukkit API - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - new RewardBuilder(reward).send(user); - } - }); - }, (count) -> { - sendMessage(sender, "&cGave all players reward file " + args[1]); - }); + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.updateTempCacheWithColumns(columns); + // Reward actions can touch Bukkit APIs, so return each one to the owner thread. + runRecipientCallback(uuid, () -> new RewardBuilder(reward).send(user)); + }, null), () -> sendMessage(sender, "&cGave all players reward file " + args[1])); } }); @@ -230,20 +221,12 @@ public void execute(CommandSender sender, String[] args) { public void executeAll(CommandSender sender, String[] args) { Reward reward = plugin.getRewardHandler().getReward(args[3]); - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - plugin.getBukkitScheduler().runTask(plugin, new Runnable() { - @Override - public void run() { - new RewardBuilder(reward).send(user); - } - }); - }, (count) -> { - sendMessage(sender, "&cGave all players reward file " + args[3]); - }); + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.updateTempCacheWithColumns(columns); + runRecipientCallback(uuid, () -> new RewardBuilder(reward).send(user)); + }, null), () -> sendMessage(sender, "&cGave all players reward file " + args[3])); } @Override @@ -280,10 +263,11 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - sendMessage(sender, "&cStarting to clear offline rewards"); - plugin.getUserManager().removeAllKeyValues(plugin.getUserManager().getOfflineRewardsPath(), - DataType.STRING); - sendMessage(sender, "&cFinished clearing offline rewards"); + sendMessage(sender, "&cStarting to clear offline rewards"); + runUserStorageCommand(sender, + () -> plugin.getUserManager().removeAllKeyValues(plugin.getUserManager().getOfflineRewardsPath(), + DataType.STRING), + () -> sendMessage(sender, "&cFinished clearing offline rewards")); } }); @@ -295,15 +279,14 @@ public void execute(CommandSender sender, String[] args) { public void execute(CommandSender sender, String[] args) { sendMessage(sender, "&cStarting to run offline rewards"); - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { - AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.updateTempCacheWithColumns(columns); - - user.forceRunOfflineRewards(); - }, (count) -> { - sendMessage(sender, "&cFinished running offline rewards"); - }); + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); + user.userDataFetechMode(UserDataFetchMode.NO_CACHE); + user.updateTempCacheWithColumns(columns); + // Replay remains on this worker. Individual player-affine reward actions + // marshal themselves through the reward scheduler when necessary. + user.forceRunOfflineRewards(); + }, null), () -> sendMessage(sender, "&cFinished running offline rewards")); } }); @@ -535,13 +518,11 @@ public void executeAll(CommandSender sender, String[] args) { final String key = args[3]; final String value = data; - plugin.getUserManager().forEachUserKeys((uuid, columns) -> { + runUserStorageCommand(sender, () -> plugin.getUserManager().forEachUserKeys((uuid, columns) -> { AdvancedCoreUser user = plugin.getUserManager().getUser(uuid, false); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); user.getData().setString(key, value); - }, (count) -> { - sender.sendMessage(MessageAPI.colorize("&cSet all users " + key + " to " + args[4])); - }); + }, null), () -> sender.sendMessage(MessageAPI.colorize("&cSet all users " + key + " to " + args[4]))); } @Override @@ -677,6 +658,47 @@ public void execute(CommandSender sender, String[] args) { return cmds; } + /** Run a shared-storage command off the primary thread and return UI work safely. */ + private void runUserStorageCommand(CommandSender sender, Runnable storageWork, Runnable onSuccess) { + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + try { + storageWork.run(); + if (onSuccess != null) runCommandCallback(sender, onSuccess); + } catch (RuntimeException | Error failure) { + if (plugin.getLogger() != null) { + plugin.getLogger().severe("Bulk user operation failed (" + failure.getClass().getSimpleName() + ")"); + } + runCommandCallback(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cUnable to process user storage; check the server log."))); + } + }); + } catch (RuntimeException | Error failure) { + if (plugin.getLogger() != null) { + plugin.getLogger().severe("Unable to schedule bulk user operation (" + failure.getClass().getSimpleName() + ")"); + } + sender.sendMessage(MessageAPI.colorize("&cUnable to process user storage; check the server log.")); + } + } + + private void runCommandCallback(CommandSender sender, Runnable callback) { + org.bukkit.entity.Entity owner = callbackOwner(sender); + if (owner == null) plugin.getBukkitScheduler().runTask(plugin, callback); + else plugin.getBukkitScheduler().runTask(plugin, callback, owner); + } + + /** + * Resolve a recipient from the global scheduler, then run player-affine work + * on that recipient's region. Offline users retain the global fallback. + */ + private void runRecipientCallback(UUID uuid, Runnable callback) { + plugin.getBukkitScheduler().runTask(plugin, () -> { + Player recipient = Bukkit.getPlayer(uuid); + if (recipient == null) callback.run(); + else plugin.getBukkitScheduler().runTask(plugin, callback, recipient); + }); + } + private void startStorageConversion(CommandSender sender, UserStorage from, UserStorage to) { sender.sendMessage(MessageAPI.colorize("&cStarting convert from " + from + " to " + to)); plugin.convertDataStorageAsync(from, to).whenComplete((ignored, failure) -> { @@ -691,9 +713,7 @@ private void startStorageConversion(CommandSender sender, UserStorage from, User + failure.getClass().getSimpleName() + ")"); sender.sendMessage(MessageAPI.colorize("&cUser storage conversion failed; see the server log")); }; - org.bukkit.entity.Entity owner = callbackOwner(sender); - if (owner == null) plugin.getBukkitScheduler().runTask(plugin, completion); - else plugin.getBukkitScheduler().runTask(plugin, completion, owner); + runCommandCallback(sender, completion); }); } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java new file mode 100644 index 000000000..34832542d --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/AdvancedCoreUuidTabCompletionRefreshTest.java @@ -0,0 +1,78 @@ +package com.bencodez.advancedcore; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.List; + +import org.bukkit.Bukkit; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.simpleapi.command.TabCompleteHandle; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; + +class AdvancedCoreUuidTabCompletionRefreshTest { + @Test + void storageEnumerationIsDeferredAndTheLatestRefreshWins() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserManager users = mock(UserManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + TabCompleteHandle handle = mock(TabCompleteHandle.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.getOptions()).thenReturn(options); + when(options.isOnlineMode()).thenReturn(true); + when(handle.getToReplace()).thenReturn("(uuid)"); + when(users.getAllUUIDs()).thenReturn(new ArrayList<>(List.of("old")), new ArrayList<>(List.of("new"))); + + ArrayList workers = new ArrayList<>(); + ArrayList mainTasks = new ArrayList<>(); + doAnswer(call -> { + workers.add(call.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { + mainTasks.add(call.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTask(any(), any()); + + AdvancedCorePlugin.UuidTabCompletionRefresh refresh = new AdvancedCorePlugin.UuidTabCompletionRefresh(); + try (MockedStatic bukkit = org.mockito.Mockito.mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(List.of()); + + refresh.request(plugin, handle); + verify(users, never()).getAllUUIDs(); + assertEquals(1, workers.size(), "request must enqueue, not enumerate on the caller thread"); + + refresh.request(plugin, handle); + assertEquals(1, workers.size(), "overlapping requests must be coalesced"); + + workers.remove(0).run(); + verify(users).getAllUUIDs(); + assertEquals(1, mainTasks.size(), "worker result must be applied on the global scheduler"); + verify(handle, never()).setReplace(any()); + + mainTasks.remove(0).run(); + verify(handle, never()).setReplace(any()); + assertEquals(1, workers.size(), "the stale completion must schedule the newest generation"); + + workers.remove(0).run(); + assertEquals(1, mainTasks.size()); + mainTasks.remove(0).run(); + + ArgumentCaptor> replacement = ArgumentCaptor.forClass(ArrayList.class); + verify(handle).setReplace(replacement.capture()); + assertEquals(List.of("new"), replacement.getValue()); + } + } +} diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java index 45655b78e..331e8a6cb 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/command/CommandLoaderBulkPermissionTest.java @@ -1,15 +1,22 @@ package com.bencodez.advancedcore.command; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.Arrays; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.function.BiConsumer; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; @@ -18,9 +25,183 @@ import com.bencodez.advancedcore.api.command.CommandHandler; import com.bencodez.advancedcore.api.command.PlayerCommandHandler; import com.bencodez.advancedcore.api.user.UserManager; +import com.bencodez.advancedcore.api.rewards.RewardHandler; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.simpleapi.sql.Column; import com.bencodez.simpleapi.scheduler.BukkitScheduler; class CommandLoaderBulkPermissionTest { + @Test + void totalUsersDefersStorageAndReturnsResultOrErrorOnTheMainScheduler() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getAllUUIDs()).thenReturn(new ArrayList<>(List.of("one", "two"))); + ArrayList workers = new ArrayList<>(); + ArrayList callbacks = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { callbacks.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTask(any(), any()); + CommandHandler command = find(new CommandLoader(plugin), "TotalNumberOfUsers"); + + command.execute(sender, new String[] { "TotalNumberOfUsers" }); + verify(users, never()).getAllUUIDs(); + assertEquals(1, workers.size()); + workers.remove(0).run(); + verify(users).getAllUUIDs(); + assertEquals(1, callbacks.size()); + callbacks.remove(0).run(); + verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("Total number of users: 2")); + + when(users.getAllUUIDs()).thenThrow(new IllegalStateException("storage unavailable")); + command.execute(sender, new String[] { "TotalNumberOfUsers" }); + workers.remove(0).run(); + callbacks.remove(0).run(); + verify(sender).sendMessage(org.mockito.ArgumentMatchers.contains("Unable to process user storage")); + } + + @Test + void runCmdAllAndGiveAllDeferEnumerationBeforeSchedulingBukkitActions() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + RewardHandler rewards = mock(RewardHandler.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + ArrayList workers = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + CommandLoader loader = new CommandLoader(plugin); + + find(loader, "RunCMD", "All", "(List)").execute(sender, new String[] { "RunCMD", "All", "say", "hi" }); + find(loader, "GiveAll", "(reward)").execute(sender, new String[] { "GiveAll", "daily" }); + verify(users, never()).forEachUserKeys(any(), any()); + assertEquals(2, workers.size()); + for (Runnable worker : List.copyOf(workers)) worker.run(); + verify(users, org.mockito.Mockito.times(2)).forEachUserKeys(any(), any()); + } + + @Test + void giveAllUsesTheRecipientEntitySchedulerForOnlineUsers() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + RewardHandler rewards = mock(RewardHandler.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + Player recipient = mock(Player.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getUser(uuid, false)).thenReturn(user); + ArrayList workers = new ArrayList<>(); + ArrayList globalCallbacks = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { globalCallbacks.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTask(any(), any()); + doAnswer(call -> { + @SuppressWarnings("unchecked") BiConsumer> perUser = call.getArgument(0, BiConsumer.class); + perUser.accept(uuid, new ArrayList<>()); + return null; + }).when(users).forEachUserKeys(any(), any()); + find(new CommandLoader(plugin), "GiveAll", "(reward)").execute(sender, new String[] { "GiveAll", "daily" }); + workers.remove(0).run(); + assertEquals(2, globalCallbacks.size()); + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(() -> org.bukkit.Bukkit.getPlayer(uuid)).thenReturn(recipient); + globalCallbacks.remove(0).run(); + } + verify(scheduler).runTask(any(), any(Runnable.class), org.mockito.ArgumentMatchers.same(recipient)); + } + + @Test + void runCmdAllUsesTheGlobalSchedulerEvenForAPlayerIssuer() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + Player sender = mock(Player.class); + AdvancedCoreUser user = mock(AdvancedCoreUser.class); + UUID uuid = UUID.randomUUID(); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getUser(uuid, false)).thenReturn(user); + when(user.getPlayerName()).thenReturn("voter"); + ArrayList workers = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { + @SuppressWarnings("unchecked") BiConsumer> perUser = call.getArgument(0, BiConsumer.class); + perUser.accept(uuid, new ArrayList<>()); + return null; + }).when(users).forEachUserKeys(any(), any()); + + find(new CommandLoader(plugin), "RunCMD", "All", "(List)") + .execute(sender, new String[] { "RunCMD", "All", "say", "hi" }); + workers.remove(0).run(); + + verify(scheduler).runTask(any(), any(Runnable.class)); + verify(scheduler, never()).runTask(any(), any(Runnable.class), org.mockito.ArgumentMatchers.same(sender)); + } + + @Test + void remainingBulkCommandsDoNotTouchWorkerOnlyStorageBeforeScheduling() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + AdvancedCoreConfigOptions options = mock(AdvancedCoreConfigOptions.class); + UserManager users = mock(UserManager.class); + RewardHandler rewards = mock(RewardHandler.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + CommandSender sender = mock(CommandSender.class); + AdvancedCoreUser offlineUser = mock(AdvancedCoreUser.class); + UUID offlineUuid = UUID.randomUUID(); + when(plugin.getOptions()).thenReturn(options); + when(plugin.getUserManager()).thenReturn(users); + when(plugin.getRewardHandler()).thenReturn(rewards); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(users.getOfflineRewardsPath()).thenReturn("OfflineRewards"); + when(users.getUser(offlineUuid, false)).thenReturn(offlineUser); + ArrayList workers = new ArrayList<>(); + doAnswer(call -> { workers.add(call.getArgument(1, Runnable.class)); return null; }) + .when(scheduler).runTaskAsynchronously(any(), any()); + doAnswer(call -> { + @SuppressWarnings("unchecked") BiConsumer> perUser = call.getArgument(0, BiConsumer.class); + perUser.accept(offlineUuid, new ArrayList<>()); + return null; + }).when(users).forEachUserKeys(any(), any()); + CommandLoader loader = new CommandLoader(plugin); + find(loader, "ClearOfflineRewards").execute(sender, new String[] { "ClearOfflineRewards" }); + find(loader, "ForceRunOfflineRewards").execute(sender, new String[] { "ForceRunOfflineRewards" }); + ((PlayerCommandHandler) find(loader, "User", "(Player)", "ForceReward", "(Reward)")) + .executeAll(sender, new String[] { "User", "all", "ForceReward", "daily" }); + ((PlayerCommandHandler) find(loader, "User", "(player)", "SetData", "(text)", "(text)")) + .executeAll(sender, new String[] { "User", "all", "SetData", "rank", "trusted" }); + verify(users, never()).removeAllKeyValues(any(), any()); + verify(users, never()).forEachUserKeys(any(), any()); + assertEquals(4, workers.size()); + workers.get(1).run(); + verify(offlineUser).forceRunOfflineRewards(); + } + + private static CommandHandler find(CommandLoader loader, String... args) { + return loader.getBasicAdminCommands("Example").stream() + .filter(handler -> Arrays.equals(handler.getArgs(), args)).findFirst().orElseThrow(); + } + @Test void setDataBulkUsesTheSharedBaseAndAllAuthorization() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedUserStorageReloadSafetyTest.java index 1b4db7d9a..dc421d296 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 @@ -15,6 +15,7 @@ import static org.mockito.Mockito.when; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -173,6 +174,43 @@ void sharedBackendReplacementCompletesOnTheManagerWorkerWithoutBlockingTheCaller } } + @Test + void retirementContinuesAfterAnInFlightReplacementFails() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + SharedUserDataRuntime runtime = mock(SharedUserDataRuntime.class); + SqlUserBackend replacement = mock(SqlUserBackend.class); + when(runtime.isClosed()).thenReturn(false); + CountDownLatch replacementStarted = new CountDownLatch(1); + CountDownLatch releaseReplacement = new CountDownLatch(1); + IllegalStateException replacementFailure = new IllegalStateException("replacement failed"); + doAnswer(ignored -> { + replacementStarted.countDown(); + assertTrue(releaseReplacement.await(5, TimeUnit.SECONDS)); + throw replacementFailure; + }).when(runtime).replaceBackend(eq(replacement), any(Runnable.class)); + when(runtime.closeAsync(any())).thenReturn(CompletableFuture.completedFuture(null)); + manager.bindSharedRuntime(runtime); + CountDownLatch retired = new CountDownLatch(1); + try { + CompletionStage replacementStage = manager.replaceSharedSqlBackendAsync(replacement); + assertTrue(replacementStarted.await(5, TimeUnit.SECONDS)); + CompletionStage retirement = manager.closeSharedRuntimeAsyncCompletion(retired::countDown); + assertFalse(retirement.toCompletableFuture().isDone()); + releaseReplacement.countDown(); + + assertThrows(java.util.concurrent.CompletionException.class, + () -> replacementStage.toCompletableFuture().join()); + assertThrows(java.util.concurrent.CompletionException.class, + () -> retirement.toCompletableFuture().join()); + assertTrue(retired.await(5, TimeUnit.SECONDS)); + verify(runtime).closeAsync(any()); + } finally { + releaseReplacement.countDown(); + manager.getTimer().shutdownNow(); + } + } + @Test void directNativeStorageMutationRejectsWhileSharedRuntimeOwnsStorage() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, CALLS_REAL_METHODS);