Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, com.bencodez.simpleapi.sql.data.DataValue> publishedPointSnapshot(VotingPluginUser user) {
var cache = plugin.getUserManager().getDataManager().getUserDataCache().get(user.getJavaUUID());
return cache == null ? null : cache.snapshotIfPublished();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1737,12 +1738,42 @@ public ArrayList<String> getOfflineVotes() {
/**
* Gets the points of the user.
*
* @return the points of the user
* <p>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.</p>
*
* @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.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UUID, UserDataCache> 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<UUID, UserDataCache> caches, ScheduledExecutorService timer) { }
}
28 changes: 28 additions & 0 deletions docs/point-shop-integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading