From 86735060bc5d3473d4ecedf81261e1f061fead5a Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 31 Oct 2026 12:15:21 -0600 Subject: [PATCH 1/4] fix(api): fail closed on unavailable shared MySQL point snapshots --- .../votingplugin/user/VotingPluginUser.java | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 3dccd557d..d33b1a2f9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.OptionalInt; import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; @@ -1737,12 +1738,42 @@ public ArrayList getOfflineVotes() { /** * Gets the points of the user. * - * @return the points of the user + *

On a Bukkit/Folia-owned thread with MySQL storage, this never waits for + * a shared-storage read. An unpublished or invalidated balance is reported as + * zero to legacy callers instead of throwing while the snapshot loads. + * This is a fail-closed compatibility fallback, not an authoritative balance: + * consumers that need to distinguish loading from a real zero should use + * {@link #getPointsIfLoaded()} and authorize spending only after a confirmed + * point mutation.

+ * + * @return the published points, or zero while shared MySQL data is unavailable */ public int getPoints() { + if (UserStorage.MYSQL.equals(plugin.getStorageType()) + && plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()) { + return getPointsIfLoaded().orElse(0); + } return getUserData().getInt(getPointsPath()); } + /** + * Reads a point balance without performing MySQL I/O while the shared + * snapshot is unavailable. Unlike {@link #getPoints()}, a missing result + * is distinguishable from a genuinely zero balance. In MySQL mode this + * method uses only a published snapshot and honors pending point-debit + * fences; ordinary storage modes retain their existing read behavior. + * + * @return the available point balance, or empty while shared MySQL points + * are loading, invalidated, or awaiting a confirmed mutation outcome + */ + public OptionalInt getPointsIfLoaded() { + if (!UserStorage.MYSQL.equals(plugin.getStorageType())) { + return OptionalInt.of(getUserData().getInt(getPointsPath())); + } + var placeholders = plugin.getPlaceholders(); + return placeholders == null ? OptionalInt.empty() : placeholders.getPointBalanceIfReady(this); + } + /** * Gets the path for the points. * From eb9fc8618b98c86edf1d206350bdb182193dadb2 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 31 Oct 2026 12:15:29 -0600 Subject: [PATCH 2/4] fix(api): share publication fence with direct points reads --- .../placeholders/PlaceHolders.java | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java index 6e8e092ea..ea384f2b4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/placeholders/PlaceHolders.java @@ -215,6 +215,40 @@ public String getPlaceholderValue(OfflinePlayer p, String identifier, boolean ja } } + /** + * Snapshot-only point balance for direct API users on Bukkit/Folia threads. + * A pending/uncertain debit or incomplete publication is unavailable, not + * a genuine zero. This shares the same mutation fence as point placeholders. + * + * @param user the requested user + * @return a published point balance, or empty until it is safe to read + */ + public java.util.OptionalInt getPointBalanceIfReady(VotingPluginUser user) { + if (user == null || !plugin.isEnabled() || plugin.getPlaceholders() != this) { + return java.util.OptionalInt.empty(); + } + UUID uuid = user.getJavaUUID(); + var generation = pointFence.generation(uuid); + try { + if (!pointFence.readable(uuid, generation)) { + if (pointFence.recoverable(uuid)) schedulePointRecovery(user); + return java.util.OptionalInt.empty(); + } + var snapshot = publishedPointSnapshot(user); + if (snapshot == null) { + schedulePointRecovery(user); + return java.util.OptionalInt.empty(); + } + Integer amount = pointBalance(snapshot, user.getPointsPath()); + if (amount == null || !pointFence.readable(uuid, generation)) { + return java.util.OptionalInt.empty(); + } + return java.util.OptionalInt.of(amount); + } finally { + pointFence.release(uuid); + } + } + private java.util.Map publishedPointSnapshot(VotingPluginUser user) { var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(user.getJavaUUID()); return cache == null ? null : cache.snapshotIfPublished(); From 51f0b5e4a9517f309d68c1be2c49359a8834f6c0 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 31 Oct 2026 12:16:16 -0600 Subject: [PATCH 3/4] test(api): cover cold, invalidated and in-flight shared point reads --- ...gPluginUserPointReadCompatibilityTest.java | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointReadCompatibilityTest.java diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointReadCompatibilityTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointReadCompatibilityTest.java new file mode 100644 index 000000000..504ec1dec --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/tests/user/VotingPluginUserPointReadCompatibilityTest.java @@ -0,0 +1,201 @@ +package com.bencodez.votingplugin.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.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; +import java.util.OptionalInt; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.data.DataValueInt; +import com.bencodez.simpleapi.sql.data.DataValueString; +import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.placeholders.PlaceHolders; +import com.bencodez.votingplugin.placeholders.PlaceholderCacheLevel; +import com.bencodez.votingplugin.user.VotingPluginUser; + +class VotingPluginUserPointReadCompatibilityTest { + private static final UUID USER_ID = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + @Test + void coldSharedMySqlSnapshotReturnsFailClosedZeroAndQueuesOneRecovery() throws Exception { + Fixture fixture = fixture("Points"); + + assertTrue(fixture.user.getPointsIfLoaded().isEmpty()); + assertEquals(0, fixture.user.getPoints()); + assertEquals(0, fixture.user.getPoints()); + verify(fixture.user, never()).getUserData(); + verify(fixture.timer, times(1)).execute(any(Runnable.class)); + } + + @Test + void publishedBalanceAndGenuineZeroRemainDistinguishableFromLoading() throws Exception { + Fixture fixture = fixture("Points"); + UserDataCache cache = published(37); + fixture.caches.put(USER_ID, cache); + + assertEquals(OptionalInt.of(37), fixture.user.getPointsIfLoaded()); + assertEquals(37, fixture.user.getPoints()); + + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(0)))); + assertEquals(OptionalInt.of(0), fixture.user.getPointsIfLoaded()); + assertEquals(0, fixture.user.getPoints()); + verify(fixture.timer, never()).execute(any(Runnable.class)); + verify(fixture.user, never()).getUserData(); + } + + @Test + void completeRowWithoutPointColumnUsesHistoricalZeroDefault() throws Exception { + Fixture fixture = fixture("Points"); + UserDataCache cache = new UserDataCache(null, USER_ID); + cache.updateCache(new HashMap<>()); + fixture.caches.put(USER_ID, cache); + + assertEquals(OptionalInt.of(0), fixture.user.getPointsIfLoaded()); + assertEquals(0, fixture.user.getPoints()); + } + + @Test + void unpublishedSnapshotCannotExposeRetainedOrInvalidatedPointValues() throws Exception { + Fixture fixture = fixture("Points"); + UserDataCache cache = published(28); + fixture.caches.put(USER_ID, cache); + + // Retiring even a different column must not make an unverified snapshot readable. + cache.invalidateStorageSnapshot("OtherColumn"); + assertTrue(fixture.user.getPointsIfLoaded().isEmpty()); + assertEquals(0, fixture.user.getPoints()); + + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(19)))); + assertEquals(OptionalInt.of(19), fixture.user.getPointsIfLoaded()); + + cache.invalidateStorageSnapshot("Points"); + assertEquals(0, fixture.user.getPoints()); + assertTrue(fixture.user.getPointsIfLoaded().isEmpty()); + + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(6)))); + assertEquals(6, fixture.user.getPoints()); + verify(fixture.user, never()).getUserData(); + } + + @Test + void pendingMutationCannotExposePreviouslyPublishedBalance() throws Exception { + Fixture fixture = fixture("Points"); + fixture.caches.put(USER_ID, published(55)); + + fixture.placeholders.beginSharedPointDebit(USER_ID); + try { + assertTrue(fixture.user.getPointsIfLoaded().isEmpty()); + assertEquals(0, fixture.user.getPoints()); + verify(fixture.user, never()).getUserData(); + } finally { + fixture.placeholders.finishSharedPointDebit(USER_ID, true, true); + } + + assertEquals(OptionalInt.of(55), fixture.user.getPointsIfLoaded()); + } + + @Test + void malformedPublishedPointValueIsNotTreatedAsKnownZero() throws Exception { + Fixture fixture = fixture("Points"); + UserDataCache cache = new UserDataCache(null, USER_ID); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueString("invalid")))); + fixture.caches.put(USER_ID, cache); + + assertTrue(fixture.user.getPointsIfLoaded().isEmpty()); + assertEquals(0, fixture.user.getPoints()); + verify(fixture.timer, never()).execute(any(Runnable.class)); + } + + @Test + void perServerPointsUseTheirOwnColumn() throws Exception { + Fixture fixture = fixture("lobby_Points"); + UserDataCache cache = new UserDataCache(null, USER_ID); + cache.updateCache(new HashMap<>(Map.of( + "Points", new DataValueInt(99), "lobby_Points", new DataValueInt(12)))); + fixture.caches.put(USER_ID, cache); + + assertEquals(OptionalInt.of(12), fixture.user.getPointsIfLoaded()); + assertEquals(12, fixture.user.getPoints()); + } + + @Test + void mysqlWorkerRetainsTheExistingAuthoritativeReadPath() throws Exception { + Fixture fixture = fixture("Points"); + when(fixture.plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()).thenReturn(false); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points")).thenReturn(63); + + assertEquals(63, fixture.user.getPoints()); + verify(data).getInt("Points"); + verify(fixture.timer, never()).execute(any(Runnable.class)); + } + + @Test + void nonMysqlStorageKeepsItsExistingReadBehavior() throws Exception { + Fixture fixture = fixture("Points"); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.SQLITE); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points")).thenReturn(18); + + assertEquals(18, fixture.user.getPoints()); + assertEquals(OptionalInt.of(18), fixture.user.getPointsIfLoaded()); + verify(data, times(2)).getInt("Points"); + verify(fixture.timer, never()).execute(any(Runnable.class)); + } + + private static UserDataCache published(int amount) { + UserDataCache cache = new UserDataCache(null, USER_ID); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(amount)))); + return cache; + } + + private static Fixture fixture(String pointColumn) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field field = VotingPluginUser.class.getDeclaredField("plugin"); + field.setAccessible(true); + field.set(user, plugin); + doReturn(USER_ID.toString()).when(user).getUUID(); + doReturn(USER_ID).when(user).getJavaUUID(); + doReturn(pointColumn).when(user).getPointsPath(); + + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getUserManager().getDataManager().mustDeferSharedStorageAccess()).thenReturn(true); + when(plugin.getConfigFile().getPlaceholderCacheLevel()).thenReturn(PlaceholderCacheLevel.AUTOALL); + + ConcurrentHashMap caches = new ConcurrentHashMap<>(); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(caches); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + + PlaceHolders placeholders = new PlaceHolders(plugin); + when(plugin.getPlaceholders()).thenReturn(placeholders); + return new Fixture(plugin, user, placeholders, caches, timer); + } + + private record Fixture(VotingPluginMain plugin, VotingPluginUser user, PlaceHolders placeholders, + ConcurrentHashMap caches, ScheduledExecutorService timer) { } +} From 79bbdb1395f8fe9e4eef06bc749c0213b2100112 Mon Sep 17 00:00:00 2001 From: Ben Date: Sat, 31 Oct 2026 12:16:30 -0600 Subject: [PATCH 4/4] docs(api): explain getPoints loading fallback and confirmed debit semantics --- docs/point-shop-integrations.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docs/point-shop-integrations.md b/docs/point-shop-integrations.md index 81afebfcb..45c453bb9 100644 --- a/docs/point-shop-integrations.md +++ b/docs/point-shop-integrations.md @@ -17,3 +17,31 @@ a snapshot refresh: those events do not confirm the original mutation. The current recovery interface does not provide the operation-bound terminal evidence needed to clear that condition automatically. Do not repeat an uncertain debit as a cache workaround. + +## Direct Java API balances + +`VotingPluginUser.getPoints()` remains an `int` for existing integrations such as +EconomyShopGUI. When called on a Bukkit/Folia-owned thread with MySQL storage, +it reads only a published point snapshot and returns **0** instead of throwing +`Shared user data is still loading` when the snapshot is unavailable. This +matches the legacy integer default but is a **fail-closed compatibility value**: +0 does not establish that the player really has no points. + +New integrations should call `VotingPluginUser.getPointsIfLoaded()` to +distinguish a known zero (`OptionalInt.of(0)`) from loading or uncertain state +(`OptionalInt.empty()`). In MySQL mode this method is a snapshot-only read, +never a blocking SQL lookup; a cache miss schedules bounded worker recovery +when that state can safely be refreshed. Other storage types retain their +existing read behavior. + +An invalidated snapshot, a pending debit or an indeterminate mutation must +not be replaced with a remembered balance. The direct API shares the +point-placeholder mutation fence. Display balances are advisory and are not +reservations: purchases must use confirmed, transaction-backed debits before +granting their rewards. An unchanged shop integration may now reject a click +as insufficient funds while the user's points load, but should not crash on +the transient read failure. Retry after the snapshot is published. + +Avoid using `UserDataFetchMode.CACHE_ONLY` as a publication check: it may +return the integer default even when the shared snapshot is not published, +without distinguishing missing data from a genuine zero.