entries = Files.newDirectoryStream(directory, "*" + SUFFIX)) {
+ for (Path entry : entries) {
+ if (purchases.size() == RECOVERY_BATCH_SIZE) break;
+ if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry)
+ || Files.size(entry) > MAX_PURCHASE_ID_BYTES) continue;
+ String purchaseId = Files.readString(entry, StandardCharsets.UTF_8);
+ if (marker(purchaseId).equals(entry.toAbsolutePath().normalize())) purchases.add(purchaseId);
+ }
+ }
+ return purchases;
+ }
+
+ void remove(String purchaseId) throws IOException {
+ DurableFiles.deleteIfExists(marker(purchaseId));
+ }
+
+ private void ensureSafeDirectory() throws IOException {
+ if (Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) {
+ if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe();
+ return;
+ }
+ Files.createDirectories(directory);
+ DurableFiles.forceDirectory(directory.getParent());
+ }
+
+ private Path marker(String purchaseId) throws IOException {
+ return directory.resolve(hash(contents(purchaseId)) + SUFFIX).toAbsolutePath().normalize();
+ }
+
+ private static byte[] contents(String purchaseId) throws IOException {
+ if (purchaseId == null || purchaseId.isBlank()) throw unsafe();
+ byte[] contents = purchaseId.getBytes(StandardCharsets.UTF_8);
+ if (contents.length > MAX_PURCHASE_ID_BYTES) throw unsafe();
+ return contents;
+ }
+
+ private static String hash(byte[] value) throws IOException {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value));
+ } catch (NoSuchAlgorithmException impossible) {
+ throw new IOException("SHA-256 is unavailable", impossible);
+ }
+ }
+
+ private static IOException unsafe() {
+ return new IOException("Unsafe vote shop compensation marker");
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java
new file mode 100644
index 0000000000..28eef10dbc
--- /dev/null
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java
@@ -0,0 +1,715 @@
+package com.bencodez.votingplugin.voteshop.service;
+
+import java.lang.ref.ReferenceQueue;
+import java.lang.ref.WeakReference;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+import com.bencodez.simpleapi.sql.mysql.DbType;
+
+/**
+ * Durable shared-MySQL vote-shop debit state.
+ *
+ * The reservation and conditional debit commit in one transaction. A reward
+ * must claim the reservation immediately before invoking the reward hook. This
+ * lets recovery refund only work which never reached that hook; a claimed row is
+ * deliberately retained for reconciliation because a reward executor can have
+ * arbitrary, non-idempotent side effects.
+ */
+final class SharedMysqlPurchaseJournal {
+ private static final String PENDING = "PENDING";
+ private static final String HOOK_STARTED = "HOOK_STARTED";
+ private static final String COMPENSATING = "COMPENSATING";
+ private static final String COMPLETED = "COMPLETED";
+ private static final String REFUNDED = "REFUNDED";
+ static final String NO_LIMIT_RESET_GENERATION = "NONE";
+ static final long PENDING_RECOVERY_AGE_MILLIS = TimeUnit.MINUTES.toMillis(5);
+ static final long TERMINAL_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7);
+ private static final int RECOVERY_BATCH_SIZE = 32;
+ private static final int CLEANUP_BATCH_SIZE = 100;
+ /* PostgreSQL permits 63 bytes and is the tighter supported database limit. */
+ private static final int MAX_IDENTIFIER_BYTES = 63;
+ private static final String JOURNAL_SUFFIX = "_VoteShopPurchases";
+ private static final String EPOCH_SUFFIX = "_VoteShopLimitEpochs";
+ private static final String HASHED_TABLE_PREFIX = "vp_vsp_";
+ private static final String HASHED_EPOCH_TABLE_PREFIX = "vp_vse_";
+ private static final int HASHED_TABLE_HEX_LENGTH = 32;
+
+ private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>();
+ private static final Set INITIALIZED = new HashSet<>();
+
+ private final MySQL table;
+ private final String journalTable;
+ private final String epochTable;
+
+ SharedMysqlPurchaseJournal(MySQL table, boolean initializeSchema) throws SQLException {
+ this.table = table;
+ journalTable = journalTableName(table.getTableName());
+ epochTable = epochTableName(table.getTableName());
+ if (initializeSchema) ensureSchema();
+ }
+
+ /**
+ * Keeps the historic auxiliary-table name where it is portable, while using
+ * a fixed, collision-resistant name for source tables which would exceed the
+ * PostgreSQL identifier limit.
+ */
+ static String journalTableName(String sourceTable) {
+ return auxiliaryTableName(sourceTable, JOURNAL_SUFFIX, HASHED_TABLE_PREFIX);
+ }
+
+ static String epochTableName(String sourceTable) {
+ return auxiliaryTableName(sourceTable, EPOCH_SUFFIX, HASHED_EPOCH_TABLE_PREFIX);
+ }
+
+ private static String auxiliaryTableName(String sourceTable, String suffix, String hashedPrefix) {
+ String legacyName = sourceTable + suffix;
+ if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName;
+ return hashedPrefix + hash(sourceTable + '\0' + suffix).substring(0, HASHED_TABLE_HEX_LENGTH);
+ }
+
+ private static String hash(String value) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder(digest.length * 2);
+ for (byte valueByte : digest) {
+ hex.append(Character.forDigit((valueByte >>> 4) & 0x0f, 16));
+ hex.append(Character.forDigit(valueByte & 0x0f, 16));
+ }
+ return hex.toString();
+ } catch (NoSuchAlgorithmException failure) {
+ throw new IllegalStateException("SHA-256 is unavailable", failure);
+ }
+ }
+
+ static SharedMysqlPurchaseJournal forTable(MySQL table) throws SQLException {
+ synchronized (INITIALIZED) {
+ expungeInitialized();
+ for (IdentityWeakReference marker : INITIALIZED) {
+ if (marker.get() == table) return new SharedMysqlPurchaseJournal(table, false);
+ }
+ new SharedMysqlPurchaseJournal(table, true);
+ INITIALIZED.add(new IdentityWeakReference(table, INITIALIZED_QUEUE));
+ return new SharedMysqlPurchaseJournal(table, false);
+ }
+ }
+
+ private static void expungeInitialized() {
+ IdentityWeakReference cleared;
+ while ((cleared = (IdentityWeakReference) INITIALIZED_QUEUE.poll()) != null) {
+ INITIALIZED.remove(cleared);
+ }
+ for (Iterator iterator = INITIALIZED.iterator(); iterator.hasNext();) {
+ if (iterator.next().get() == null) iterator.remove();
+ }
+ }
+
+ /** Atomically records a pending purchase and conditionally charges it. */
+ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit,
+ String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException {
+ String insert = "INSERT INTO " + qiJournal() + " (" + qi("purchase_id") + ", " + qi("player_uuid")
+ + ", " + qi("points_column") + ", " + qi("limit_column") + ", " + qi("cost") + ", "
+ + qi("limit_value") + ", " + qi("limit_generation") + ", "
+ + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + ", " + qi("state")
+ + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+ String points = qi(pointsColumn);
+ StringBuilder debit = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
+ .append(points).append(" = ").append(points).append(" - ?");
+ if (limitColumn != null) {
+ debit.append(", ").append(qi(limitColumn)).append(" = COALESCE(").append(qi(limitColumn))
+ .append(", 0) + 1");
+ }
+ debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND ").append(points)
+ .append(" >= ?");
+ if (limitColumn != null) {
+ debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?");
+ }
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn);
+ try (PreparedStatement insertStatement = connection.prepareStatement(insert);
+ PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) {
+ insertStatement.setString(1, purchaseId);
+ insertStatement.setString(2, uuid);
+ insertStatement.setString(3, pointsColumn);
+ insertStatement.setString(4, limitColumn);
+ insertStatement.setInt(5, cost);
+ if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER);
+ else insertStatement.setInt(6, limit);
+ if (limitGeneration == null) insertStatement.setNull(7, java.sql.Types.VARCHAR);
+ else insertStatement.setString(7, limitGeneration);
+ if (limitGenerationExpiresAt <= 0L) insertStatement.setNull(8, java.sql.Types.BIGINT);
+ else insertStatement.setLong(8, limitGenerationExpiresAt);
+ if (limitEpoch == null) insertStatement.setNull(9, java.sql.Types.BIGINT);
+ else insertStatement.setLong(9, limitEpoch.longValue());
+ insertStatement.setString(10, PENDING);
+ insertStatement.setLong(11, now);
+ insertStatement.executeUpdate();
+
+ debitStatement.setInt(1, cost);
+ debitStatement.setString(2, uuid);
+ debitStatement.setInt(3, cost);
+ if (limitColumn != null) debitStatement.setInt(4, limit);
+ if (debitStatement.executeUpdate() != 1) {
+ rollback(connection);
+ return false;
+ }
+ return commitAndConfirm(connection, purchaseId, PENDING);
+ }
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ /**
+ * Wipes a resettable limit and advances its epoch while holding the same row
+ * that reservations lock before they debit. A reservation can therefore land
+ * wholly before or wholly after the reset, never in the wiped interval.
+ */
+ void resetLimit(String limitColumn, String resetGeneration) throws SQLException {
+ if (!isSafeColumn(limitColumn)) throw new SQLException("Unsafe vote shop limit column");
+ if (resetGeneration == null || resetGeneration.isEmpty() || resetGeneration.length() > 128) {
+ throw new SQLException("Invalid vote shop reset generation");
+ }
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try {
+ EpochRow marker = lockLimitEpochRow(connection, limitColumn);
+ if (resetGeneration.equals(marker.lastResetGeneration())) {
+ rollback(connection);
+ return;
+ }
+ long oldEpoch = marker.epoch();
+ if (oldEpoch == Long.MAX_VALUE) throw new SQLException("Vote shop limit epoch overflow");
+ long expectedEpoch = oldEpoch + 1L;
+ try (PreparedStatement wipe = connection.prepareStatement("UPDATE " + qi(table.getTableName())
+ + " SET " + qi(limitColumn) + " = 0");
+ PreparedStatement advance = connection.prepareStatement("UPDATE " + qiEpoch() + " SET "
+ + qi("epoch") + " = ?, " + qi("last_reset_generation") + " = ? WHERE "
+ + qi("limit_column") + " = ?")) {
+ wipe.executeUpdate();
+ advance.setLong(1, expectedEpoch);
+ advance.setString(2, resetGeneration);
+ advance.setString(3, limitColumn);
+ if (advance.executeUpdate() != 1) throw new SQLException("Vote shop limit epoch marker missing");
+ }
+ try {
+ connection.commit();
+ } catch (SQLException ambiguousCommit) {
+ closeQuietly(connection);
+ EpochRow confirmed = findLimitEpoch(limitColumn);
+ if (confirmed != null && resetGeneration.equals(confirmed.lastResetGeneration())) return;
+ throw ambiguousCommit;
+ }
+ } catch (SQLException failure) {
+ rollback(connection);
+ throw failure;
+ }
+ }
+ }
+
+ /**
+ * A JDBC commit error does not prove that the database discarded the
+ * transaction. Close the possibly-broken handle before looking up the same
+ * id, which also keeps a one-connection pool from deadlocking itself.
+ */
+ private boolean commitAndConfirm(Connection connection, String purchaseId, String expectedState)
+ throws SQLException {
+ try {
+ connection.commit();
+ return true;
+ } catch (SQLException ambiguousCommit) {
+ closeQuietly(connection);
+ PurchaseRow row = find(purchaseId);
+ if (row != null && expectedState.equals(row.state())) return true;
+ throw ambiguousCommit;
+ }
+ }
+
+ private PurchaseRow find(String purchaseId) throws SQLException {
+ String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("purchase_id")
+ + " = ?";
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, purchaseId);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? new PurchaseRow(result.getString(1)) : null;
+ }
+ }
+ }
+
+ /** Claims a still-pending debit immediately before the external reward hook. */
+ ClaimOutcome claimReward(String purchaseId, long startedAt) throws SQLException {
+ String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("hook_started_at")
+ + " = ? WHERE " + qi("purchase_id") + " = ? AND " + qi("state") + " = ?";
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) {
+ statement.setString(1, HOOK_STARTED);
+ statement.setLong(2, startedAt);
+ statement.setString(3, purchaseId);
+ statement.setString(4, PENDING);
+ try {
+ return statement.executeUpdate() == 1 ? ClaimOutcome.CLAIMED : ClaimOutcome.NOT_CLAIMED;
+ } catch (SQLException ambiguousUpdate) {
+ // An autocommit update can reach the database even when its acknowledgement
+ // does not reach this process. Release the suspect handle before confirming
+ // through a fresh connection, including with a one-connection pool.
+ closeQuietly(connection);
+ try {
+ PurchaseRow row = find(purchaseId);
+ if (row != null && HOOK_STARTED.equals(row.state())) return ClaimOutcome.CLAIMED;
+ if (row != null && PENDING.equals(row.state())) return ClaimOutcome.NOT_CLAIMED;
+ } catch (SQLException confirmationFailure) {
+ ambiguousUpdate.addSuppressed(confirmationFailure);
+ }
+ return ClaimOutcome.INDETERMINATE;
+ }
+ }
+ }
+
+ void complete(String purchaseId) throws SQLException {
+ setTerminal(purchaseId, COMPLETED, 0L);
+ }
+
+ /** Refunds only a debit whose reward hook has not started. */
+ boolean refundPending(String purchaseId) throws SQLException {
+ return refundPending(purchaseId, System.currentTimeMillis());
+ }
+
+ boolean refundPending(String purchaseId, long now) throws SQLException {
+ return setTerminal(purchaseId, REFUNDED, now, PENDING);
+ }
+
+ RefundedPurchase refundPendingDetails(String purchaseId, long now) throws SQLException {
+ return setTerminalDetails(purchaseId, REFUNDED, now, PENDING);
+ }
+
+ /**
+ * Compensates a pending or claimed purchase only when the local scheduler
+ * guard proves that its reward callback cannot run. The intermediate durable
+ * state makes a failed refund retryable after a database outage or restart.
+ */
+ boolean refundUnstartedReward(String purchaseId) throws SQLException {
+ SQLException lastFailure = null;
+ for (int attempt = 0; attempt < 3; attempt++) {
+ try {
+ if (!requestUnstartedRewardRefund(purchaseId)) {
+ PurchaseRow row = find(purchaseId);
+ return row != null && REFUNDED.equals(row.state());
+ }
+ return refundCompensatingReward(purchaseId);
+ } catch (SQLException failure) {
+ lastFailure = failure;
+ }
+ }
+ throw lastFailure;
+ }
+
+ /**
+ * Durably fences a rejected reward callback before another scheduler is used.
+ *
+ * The caller has already won the local scheduler state race, so recovery may
+ * safely refund this row even if the persistence or Bukkit fallback scheduler
+ * is rejected or the process stops before its refund task starts.
+ */
+ boolean markCompensating(String purchaseId) throws SQLException {
+ SQLException lastFailure = null;
+ for (int attempt = 0; attempt < 3; attempt++) {
+ try {
+ return requestUnstartedRewardRefund(purchaseId);
+ } catch (SQLException failure) {
+ lastFailure = failure;
+ }
+ }
+ throw lastFailure;
+ }
+
+ /** Durable marker used before attempting compensation, so recovery can retry it. */
+ private boolean requestUnstartedRewardRefund(String purchaseId) throws SQLException {
+ String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
+ + " = ? AND " + qi("state") + " IN (?, ?, ?)";
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) {
+ connection.setAutoCommit(false);
+ statement.setString(1, COMPENSATING);
+ statement.setString(2, purchaseId);
+ statement.setString(3, PENDING);
+ statement.setString(4, HOOK_STARTED);
+ statement.setString(5, COMPENSATING);
+ if (statement.executeUpdate() != 1) return false;
+ return commitAndConfirm(connection, purchaseId, COMPENSATING);
+ }
+ }
+
+ /** Retries the already-marked compensation without reopening the hook. */
+ boolean refundCompensatingReward(String purchaseId) throws SQLException {
+ return setTerminal(purchaseId, REFUNDED, System.currentTimeMillis(), COMPENSATING);
+ }
+
+ private RefundedPurchase refundCompensatingRewardDetails(String purchaseId) throws SQLException {
+ return setTerminalDetails(purchaseId, REFUNDED, System.currentTimeMillis(), COMPENSATING);
+ }
+
+ private boolean setTerminal(String purchaseId, String terminalState, long now, String... refundableStates)
+ throws SQLException {
+ return setTerminalDetails(purchaseId, terminalState, now, refundableStates) != null;
+ }
+
+ private RefundedPurchase setTerminalDetails(String purchaseId, String terminalState, long now,
+ String... refundableStates)
+ throws SQLException {
+ boolean refund = REFUNDED.equals(terminalState);
+ String refundedUuid = null;
+ String refundedPointsColumn = null;
+ String refundedLimitColumn = null;
+ String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column")
+ + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", "
+ + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE "
+ + qi("purchase_id") + " = ? FOR UPDATE";
+ try (Connection connection = connection()) {
+ connection.setAutoCommit(false);
+ try (PreparedStatement selectStatement = connection.prepareStatement(select)) {
+ selectStatement.setString(1, purchaseId);
+ try (ResultSet result = selectStatement.executeQuery()) {
+ if (!result.next()) {
+ rollback(connection);
+ return null;
+ }
+ String state = result.getString(1);
+ if (COMPLETED.equals(state) || REFUNDED.equals(state)) {
+ rollback(connection);
+ return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null;
+ }
+ if (refund && !isRefundableState(state, refundableStates)) {
+ rollback(connection);
+ return null;
+ }
+ if (!refund && !HOOK_STARTED.equals(state)) {
+ rollback(connection);
+ return null;
+ }
+ String uuid = result.getString(2);
+ String pointsColumn = result.getString(3);
+ String limitColumn = result.getString(4);
+ refundedUuid = uuid;
+ refundedPointsColumn = pointsColumn;
+ refundedLimitColumn = limitColumn;
+ int cost = result.getInt(5);
+ String limitGeneration = result.getString(6);
+ Long limitEpoch = nullableLong(result, 8);
+ if (refund) {
+ refund(connection, uuid, pointsColumn, limitColumn, cost,
+ shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch));
+ }
+ }
+ }
+ String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id")
+ + " = ?";
+ try (PreparedStatement updateStatement = connection.prepareStatement(update)) {
+ updateStatement.setString(1, terminalState);
+ updateStatement.setString(2, purchaseId);
+ if (updateStatement.executeUpdate() != 1) {
+ rollback(connection);
+ return null;
+ }
+ }
+ if (!commitAndConfirm(connection, purchaseId, terminalState)) return null;
+ return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn)
+ : new RefundedPurchase(null, null, null);
+ } catch (SQLException failure) {
+ throw failure;
+ }
+ }
+
+ private static boolean isRefundableState(String state, String... refundableStates) {
+ for (String refundableState : refundableStates) {
+ if (refundableState.equals(state)) return true;
+ }
+ return false;
+ }
+
+ private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost,
+ boolean refundLimit) throws SQLException {
+ if (!isSafeColumn(pointsColumn) || (limitColumn != null && !isSafeColumn(limitColumn))) {
+ throw new SQLException("Unsafe durable purchase column");
+ }
+ StringBuilder refund = new StringBuilder("UPDATE ").append(qi(table.getTableName())).append(" SET ")
+ .append(qi(pointsColumn)).append(" = ").append(qi(pointsColumn)).append(" + ?");
+ if (refundLimit) {
+ refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn))
+ .append(", 0) - 1, 0)");
+ }
+ refund.append(" WHERE ").append(qi("uuid")).append(uuidCast());
+ try (PreparedStatement statement = connection.prepareStatement(refund.toString())) {
+ statement.setInt(1, cost);
+ statement.setString(2, uuid);
+ if (statement.executeUpdate() != 1) throw new SQLException("Purchase refund player missing");
+ }
+ }
+
+ private boolean shouldRefundLimit(Connection connection, String limitColumn, String generation, Long storedEpoch)
+ throws SQLException {
+ if (limitColumn == null) return false;
+ if (storedEpoch != null) {
+ EpochRow currentEpoch = findAndLockLimitEpoch(connection, limitColumn);
+ return currentEpoch != null && storedEpoch.longValue() == currentEpoch.epoch();
+ }
+ // Legacy rows did not capture a durable epoch, so a resettable limit cannot
+ // be identified safely. NONE has never reset and keeps its historic refund.
+ return NO_LIMIT_RESET_GENERATION.equals(generation);
+ }
+
+ List recoverAndCleanup(long now) throws SQLException {
+ long cutoff = now - PENDING_RECOVERY_AGE_MILLIS;
+ String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state")
+ + " = ? AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?";
+ List pending = new ArrayList<>();
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, PENDING);
+ statement.setLong(2, cutoff);
+ statement.setInt(3, RECOVERY_BATCH_SIZE);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) pending.add(result.getString(1));
+ }
+ }
+ List refunded = new ArrayList<>();
+ for (String purchaseId : pending) {
+ RefundedPurchase result = refundPendingDetails(purchaseId, now);
+ if (result != null) refunded.add(result);
+ }
+ // COMPENSATING is safe to refund: the local scheduler fence was persisted
+ // before the first attempt, so the reward callback cannot run. Retry these
+ // rows promptly after an outage rather than leaving them charged forever.
+ for (String purchaseId : findTransferIds(COMPENSATING, RECOVERY_BATCH_SIZE)) {
+ RefundedPurchase result = refundCompensatingRewardDetails(purchaseId);
+ if (result != null) refunded.add(result);
+ }
+ cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS);
+ return List.copyOf(refunded);
+ }
+
+ private List findTransferIds(String state, int limit) throws SQLException {
+ String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state")
+ + " = ? ORDER BY " + qi("created_at") + " ASC LIMIT ?";
+ List purchaseIds = new ArrayList<>();
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, state);
+ statement.setInt(2, limit);
+ try (ResultSet result = statement.executeQuery()) {
+ while (result.next()) purchaseIds.add(result.getString(1));
+ }
+ }
+ return purchaseIds;
+ }
+
+ private void cleanupTerminalRows(long cutoff) throws SQLException {
+ String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state")
+ + " IN (?, ?) AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?";
+ String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("purchase_id") + " = ? AND "
+ + qi("state") + " IN (?, ?) AND " + qi("created_at") + " <= ?";
+ try (Connection connection = connection(); PreparedStatement selectStatement = connection.prepareStatement(select);
+ PreparedStatement deleteStatement = connection.prepareStatement(delete)) {
+ selectStatement.setString(1, COMPLETED);
+ selectStatement.setString(2, REFUNDED);
+ selectStatement.setLong(3, cutoff);
+ selectStatement.setInt(4, CLEANUP_BATCH_SIZE);
+ List terminal = new ArrayList<>();
+ try (ResultSet result = selectStatement.executeQuery()) {
+ while (result.next()) terminal.add(result.getString(1));
+ }
+ for (String purchaseId : terminal) {
+ deleteStatement.setString(1, purchaseId);
+ deleteStatement.setString(2, COMPLETED);
+ deleteStatement.setString(3, REFUNDED);
+ deleteStatement.setLong(4, cutoff);
+ deleteStatement.executeUpdate();
+ }
+ }
+ }
+
+ private void ensureSchema() throws SQLException {
+ String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("purchase_id")
+ + " VARCHAR(36) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NOT NULL, "
+ + qi("points_column") + " VARCHAR(128) NOT NULL, " + qi("limit_column") + " VARCHAR(128) NULL, "
+ + qi("cost") + " INT NOT NULL, " + qi("limit_value") + " INT NULL, " + qi("limit_generation")
+ + " VARCHAR(96) NULL, " + qi("limit_generation_expires_at") + " BIGINT NULL, " + qi("limit_epoch")
+ + " BIGINT NULL, " + qi("state")
+ + " VARCHAR(16) NOT NULL, " + qi("created_at") + " BIGINT NOT NULL, " + qi("hook_started_at")
+ + " BIGINT NULL, PRIMARY KEY (" + qi("purchase_id") + "));";
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) {
+ statement.executeUpdate();
+ ensureColumn(connection, "limit_generation", "VARCHAR(96) NULL");
+ ensureColumn(connection, "limit_generation_expires_at", "BIGINT NULL");
+ ensureColumn(connection, "limit_epoch", "BIGINT NULL");
+ ensureEpochSchema(connection);
+ String index = "vp_vsp_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_state_created";
+ String createIndex = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "")
+ + qi(index) + " ON " + qiJournal() + " (" + qi("state") + ", " + qi("created_at") + ");";
+ try (PreparedStatement indexStatement = connection.prepareStatement(createIndex)) {
+ indexStatement.executeUpdate();
+ } catch (SQLException failure) {
+ if (failure.getErrorCode() != 1061 && !"42P07".equals(failure.getSQLState())) throw failure;
+ }
+ }
+ }
+
+ private void ensureEpochSchema(Connection connection) throws SQLException {
+ String create = "CREATE TABLE IF NOT EXISTS " + qiEpoch() + " (" + qi("limit_column")
+ + " VARCHAR(128) NOT NULL, " + qi("epoch") + " BIGINT NOT NULL, "
+ + qi("last_reset_generation") + " VARCHAR(128) NULL, PRIMARY KEY ("
+ + qi("limit_column") + "));";
+ try (PreparedStatement statement = connection.prepareStatement(create)) {
+ statement.executeUpdate();
+ }
+ ensureEpochColumn(connection, "last_reset_generation", "VARCHAR(128) NULL");
+ }
+
+ private void ensureEpochColumn(Connection connection, String column, String definition) throws SQLException {
+ String alter = "ALTER TABLE " + qiEpoch() + " ADD COLUMN " + qi(column) + " " + definition;
+ try (PreparedStatement statement = connection.prepareStatement(alter)) {
+ statement.executeUpdate();
+ } catch (SQLException failure) {
+ if (failure.getErrorCode() != 1060 && !"42701".equals(failure.getSQLState())) throw failure;
+ }
+ }
+
+ private void ensureColumn(Connection connection, String column, String definition) throws SQLException {
+ String alter = "ALTER TABLE " + qiJournal() + " ADD COLUMN " + qi(column) + " " + definition;
+ try (PreparedStatement statement = connection.prepareStatement(alter)) {
+ statement.executeUpdate();
+ } catch (SQLException failure) {
+ if (failure.getErrorCode() != 1060 && !"42701".equals(failure.getSQLState())) throw failure;
+ }
+ }
+
+ private Connection connection() throws SQLException {
+ Connection connection = table.getMysql().getConnectionManager().getConnection();
+ if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection");
+ return connection;
+ }
+
+ private String qiJournal() { return table.qi(journalTable); }
+ private String qiEpoch() { return table.qi(epochTable); }
+ private String qi(String identifier) { return table.qi(identifier); }
+ private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; }
+
+ private long lockLimitEpoch(Connection connection, String limitColumn) throws SQLException {
+ return lockLimitEpochRow(connection, limitColumn).epoch();
+ }
+
+ private EpochRow lockLimitEpochRow(Connection connection, String limitColumn) throws SQLException {
+ ensureLimitEpochRow(connection, limitColumn);
+ EpochRow epoch = findAndLockLimitEpoch(connection, limitColumn);
+ if (epoch == null) throw new SQLException("Vote shop limit epoch marker missing");
+ return epoch;
+ }
+
+ private void ensureLimitEpochRow(Connection connection, String limitColumn) throws SQLException {
+ String insert = table.getDbType() == DbType.POSTGRESQL
+ ? "INSERT INTO " + qiEpoch() + " (" + qi("limit_column") + ", " + qi("epoch")
+ + ") VALUES (?, 0) ON CONFLICT DO NOTHING"
+ : "INSERT IGNORE INTO " + qiEpoch() + " (" + qi("limit_column") + ", " + qi("epoch")
+ + ") VALUES (?, 0)";
+ try (PreparedStatement statement = connection.prepareStatement(insert)) {
+ statement.setString(1, limitColumn);
+ statement.executeUpdate();
+ }
+ }
+
+ private EpochRow findAndLockLimitEpoch(Connection connection, String limitColumn) throws SQLException {
+ String select = "SELECT " + qi("epoch") + ", " + qi("last_reset_generation") + " FROM " + qiEpoch()
+ + " WHERE " + qi("limit_column")
+ + " = ? FOR UPDATE";
+ try (PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, limitColumn);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? new EpochRow(result.getLong(1), result.getString(2)) : null;
+ }
+ }
+ }
+
+ private EpochRow findLimitEpoch(String limitColumn) throws SQLException {
+ String select = "SELECT " + qi("epoch") + ", " + qi("last_reset_generation") + " FROM " + qiEpoch()
+ + " WHERE " + qi("limit_column")
+ + " = ?";
+ try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) {
+ statement.setString(1, limitColumn);
+ try (ResultSet result = statement.executeQuery()) {
+ return result.next() ? new EpochRow(result.getLong(1), result.getString(2)) : null;
+ }
+ }
+ }
+
+ private static Long nullableLong(ResultSet result, int index) throws SQLException {
+ Object value = result.getObject(index);
+ return value instanceof Number number ? number.longValue() : null;
+ }
+
+ private record EpochRow(long epoch, String lastResetGeneration) {
+ }
+
+ private static boolean isSafeColumn(String column) {
+ // Columns are passed through AbstractSqlTable.qi(), which escapes the
+ // database-specific identifier delimiter. Preserve configured shop keys
+ // such as "Daily Reward" in the durable journal so their debit can always
+ // be recovered; reject only values that cannot be represented by its
+ // bounded VARCHAR journal column or a SQL identifier.
+ return column != null && !column.isEmpty() && column.length() <= 128 && column.indexOf('\0') < 0;
+ }
+
+ private static void rollback(Connection connection) {
+ try {
+ connection.rollback();
+ } catch (SQLException ignored) {
+ // Preserve the original failure; a PENDING record remains recoverable.
+ }
+ }
+
+ private static void closeQuietly(Connection connection) {
+ try {
+ connection.close();
+ } catch (SQLException ignored) {
+ // The confirmation query above decides whether the durable commit landed.
+ }
+ }
+
+ private record PurchaseRow(String state) {
+ }
+
+ record RefundedPurchase(String uuid, String pointsColumn, String limitColumn) {
+ }
+
+ enum ClaimOutcome {
+ CLAIMED,
+ NOT_CLAIMED,
+ INDETERMINATE
+ }
+
+ private static final class IdentityWeakReference extends WeakReference {
+ private final int identityHash;
+
+ IdentityWeakReference(MySQL referent, ReferenceQueue queue) {
+ super(referent, queue);
+ identityHash = System.identityHashCode(referent);
+ }
+
+ @Override public int hashCode() { return identityHash; }
+
+ @Override public boolean equals(Object other) {
+ return this == other || other instanceof IdentityWeakReference reference && get() != null
+ && get() == reference.get();
+ }
+ }
+}
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java
index fd581cab3f..b03baf9c61 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseResult.java
@@ -9,6 +9,9 @@
public enum VoteShopPurchaseResult {
SUCCESS,
+ PENDING,
+ RECONCILIATION_REQUIRED,
+ FAILED,
SHOP_DISABLED,
ITEM_NOT_FOUND,
NO_PERMISSION,
diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java
index e4d91b026d..f1a3fb179b 100644
--- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java
+++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java
@@ -1,15 +1,40 @@
package com.bencodez.votingplugin.voteshop.service;
+import java.io.IOException;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.WeekFields;
import java.util.HashMap;
+import java.util.Locale;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
import org.bukkit.Bukkit;
+import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player;
import com.bencodez.advancedcore.api.messages.PlaceholderUtils;
import com.bencodez.advancedcore.api.rewards.RewardOptions;
+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.userstorage.mysql.MySQL;
+import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;
+import com.bencodez.simpleapi.sql.DataType;
+import com.bencodez.simpleapi.sql.mysql.DbType;
import com.bencodez.votingplugin.VotingPluginMain;
import com.bencodez.votingplugin.events.VoteShopPurchaseEvent;
import com.bencodez.votingplugin.user.VotingPluginUser;
+import com.bencodez.votingplugin.user.SharedMysqlCacheReconciler;
+import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition;
import com.bencodez.votingplugin.voteshop.shop.VoteShopItem;
@@ -22,6 +47,20 @@
@Getter
@Setter
public class VoteShopPurchaseService {
+ private static final int PURCHASE_LOCK_STRIPES = 256;
+ private static final Object[] PURCHASE_LOCKS = createPurchaseLocks();
+ private static final int COMPLETION_PENDING = 0;
+ private static final int COMPLETION_RUNNING = 1;
+ private static final int COMPLETION_COMPENSATING = 2;
+ private static final int COMPLETION_FINISHED = 3;
+ /*
+ * Reset generations are persisted and compared by every backend sharing a
+ * MySQL table. Do not derive them from a host locale: that makes Sunday- and
+ * Monday-first JVMs publish different generations for one network period.
+ * Locale.ROOT is the historic convention here, but retain the WeekFields as a
+ * constant so the network contract is explicit and cannot track JVM defaults.
+ */
+ private static final WeekFields NETWORK_WEEK_FIELDS = WeekFields.of(Locale.ROOT);
private VoteShopDefinition definition;
@@ -47,6 +86,35 @@ public VoteShopPurchaseService(VotingPluginMain plugin, VoteShopDefinition defin
* @return the result
*/
public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser user, VoteShopItem item) {
+ VoteShopPurchaseResult staticValidation = validateStaticPurchase(player, item);
+ if (staticValidation != VoteShopPurchaseResult.SUCCESS) {
+ return staticValidation;
+ }
+ // Shared points and limits are decided atomically by the queued reservation.
+ // GUI rendering/click validation runs on Bukkit/Folia lanes and must not turn
+ // an advisory precheck into a synchronous database read.
+ if (usesMysqlPurchaseReservation(item)) return VoteShopPurchaseResult.SUCCESS;
+ if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) {
+ return VoteShopPurchaseResult.LIMIT_REACHED;
+ }
+ if (user.getPoints() < item.getCost()) {
+ return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
+ }
+ return VoteShopPurchaseResult.SUCCESS;
+ }
+
+ /** Refreshes dynamic GUI validation state only when that refresh cannot block on shared MySQL. */
+ public void refreshUserForPurchaseValidation(VotingPluginUser user, VoteShopItem item, boolean requested) {
+ if (requested && !usesMysqlPurchaseReservation(item)) user.cache();
+ }
+
+ /** @deprecated Pass the item so limited MySQL purchases can avoid a blocking refresh. */
+ @Deprecated
+ public void refreshUserForPurchaseValidation(VotingPluginUser user, boolean requested) {
+ refreshUserForPurchaseValidation(user, null, requested);
+ }
+
+ private VoteShopPurchaseResult validateStaticPurchase(Player player, VoteShopItem item) {
if (!definition.isEnabled()) {
return VoteShopPurchaseResult.SHOP_DISABLED;
}
@@ -59,12 +127,6 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u
if (!hasPermission(player, item.getPermission())) {
return VoteShopPurchaseResult.NO_PERMISSION;
}
- if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) {
- return VoteShopPurchaseResult.LIMIT_REACHED;
- }
- if (user.getPoints() < item.getCost()) {
- return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
- }
return VoteShopPurchaseResult.SUCCESS;
}
@@ -76,26 +138,341 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u
* @param item the item
* @return the result
*/
- public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) {
+ private VoteShopPurchaseResult purchaseLocal(Player player, VotingPluginUser user, VoteShopItem item) {
+ if (plugin.getConfigFile().isExtraVoteShopCheck()) user.cache();
VoteShopPurchaseResult validation = validatePurchase(player, user, item);
if (validation != VoteShopPurchaseResult.SUCCESS) {
return validation;
}
+ FileConfiguration shopData = plugin.getShopFile().getData();
HashMap placeholders = new HashMap();
placeholders.put("identifier", item.getIdentifierName());
placeholders.put("points", String.valueOf(item.getCost()));
placeholders.put("limit", String.valueOf(item.getLimit()));
placeholders.put("shop", definition.getTitle());
- if (!user.removePoints(item.getCost(), true)) {
- return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
+ VoteShopPurchaseResult debit = debitForPurchase(user, item);
+ if (debit != VoteShopPurchaseResult.SUCCESS) {
+ return debit;
+ }
+ completePurchase(player, user, item, placeholders, shopData);
+ return VoteShopPurchaseResult.SUCCESS;
+ }
+
+ /**
+ * Executes a purchase and reports its result on the Bukkit thread. Shared
+ * MySQL debits run on AdvancedCore's ordered persistence executor so earlier
+ * asynchronous user writes complete before the conditional debit.
+ *
+ * @param player the player
+ * @param user the user
+ * @param item the item
+ * @param completion completion callback
+ */
+ public void purchase(Player player, VotingPluginUser user, VoteShopItem item,
+ Consumer completion) {
+ if (!usesMysqlPurchaseReservation(item)) {
+ completion.accept(purchaseLocal(player, user, item));
+ return;
}
+ VoteShopPurchaseResult validation = validateStaticPurchase(player, item);
+ if (validation != VoteShopPurchaseResult.SUCCESS) {
+ completion.accept(validation);
+ return;
+ }
+ // Keep the loaded configuration object with the queued purchase. reloadData()
+ // replaces ShopFile's FileConfiguration, so looking it up after the worker
+ // or entity task runs could pair an old debit with a newly loaded reward.
+ FileConfiguration shopData = plugin.getShopFile().getData();
+ HashMap placeholders = purchasePlaceholders(item);
+ try {
+ plugin.getTimer().execute(() -> {
+ try {
+ SharedPurchaseDebit debit;
+ synchronized (purchaseLock(user.getUUID())) {
+ // Sample the reset window beside the conditional debit. A queued
+ // persistence task may otherwise cross into a new limit period.
+ debit = reserveSharedMysqlPurchase(user, item,
+ limitGeneration(item, System.currentTimeMillis()));
+ }
+ if (debit.result() != VoteShopPurchaseResult.SUCCESS) {
+ BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(debit.result()));
+ return;
+ }
+ completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit);
+ } catch (RuntimeException workerFailure) {
+ plugin.debug(workerFailure);
+ completeFailedPurchase(player, completion);
+ }
+ });
+ } catch (RuntimeException persistenceRejected) {
+ plugin.debug(persistenceRejected);
+ completeFailedPurchase(player, completion);
+ }
+ }
+
+ /**
+ * Compatibility entry point for integrations compiled against the synchronous
+ * API. Shared-MySQL purchases return {@link VoteShopPurchaseResult#PENDING}
+ * after static validation because their final debit result is asynchronous;
+ * use the callback overload when the final result is required.
+ *
+ * @deprecated use {@link #purchase(Player, VotingPluginUser, VoteShopItem, Consumer)}
+ */
+ @Deprecated
+ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) {
+ if (!usesMysqlPurchaseReservation(item)) return purchaseLocal(player, user, item);
+ VoteShopPurchaseResult validation = validateStaticPurchase(player, item);
+ if (validation != VoteShopPurchaseResult.SUCCESS) return validation;
+ purchase(player, user, item, ignored -> { });
+ return VoteShopPurchaseResult.PENDING;
+ }
+
+ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item,
+ HashMap placeholders, FileConfiguration shopData,
+ Consumer completion, SharedPurchaseDebit debit) {
+ AtomicInteger state = new AtomicInteger(COMPLETION_PENDING);
+ Runnable compensateBeforeClaim = () -> {
+ if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return;
+ scheduleSharedMysqlCompensation(player, user, completion, debit);
+ };
+ try {
+ /*
+ * The first entity callback is only a nonblocking scheduling gate. Keeping
+ * the durable row PENDING until it starts lets recovery refund a debit when
+ * the entity scheduler never accepts work. The JDBC claim then runs off the
+ * entity lane, and only a successful durable claim schedules the actual
+ * reward callback. BukkitCompletionScheduler retains that entity/global
+ * fallback behavior on Folia and safely uses Bukkit scheduling when Folia
+ * support is absent.
+ */
+ runPurchaseEntityTask(player, () -> {
+ if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return;
+ try {
+ claimSharedMysqlPurchaseAsync(debit).whenComplete((claim, failure) -> {
+ if (failure != null || requiresCompensation(claim)) {
+ if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) {
+ // runTaskAsynchronously may reject before returning its future.
+ // CompletableFuture then invokes this callback inline on the
+ // entity lane, so compensation must be admitted through its own
+ // off-thread scheduling path instead of doing JDBC here.
+ scheduleSharedMysqlCompensation(player, user, completion, debit);
+ }
+ return;
+ }
+ state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED);
+ scheduleClaimedReward(player, user, item, placeholders, shopData, completion, debit);
+ });
+ } catch (RuntimeException claimSchedulingFailure) {
+ if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) {
+ scheduleSharedMysqlCompensation(player, user, completion, debit);
+ }
+ plugin.debug(claimSchedulingFailure);
+ }
+ }, compensateBeforeClaim);
+ } catch (RuntimeException schedulingFailure) {
+ compensateBeforeClaim.run();
+ plugin.debug(schedulingFailure);
+ }
+ }
+
+ static boolean requiresCompensation(SharedMysqlPurchaseJournal.ClaimOutcome claim) {
+ // The local reward callback has not started yet, so both a rejected claim
+ // and an unconfirmed claim are safe to fence and refund.
+ return claim != SharedMysqlPurchaseJournal.ClaimOutcome.CLAIMED;
+ }
+
+ void scheduleClaimedReward(Player player, VotingPluginUser user, VoteShopItem item,
+ HashMap placeholders, FileConfiguration shopData,
+ Consumer completion, SharedPurchaseDebit debit) {
+ AtomicInteger state = new AtomicInteger(COMPLETION_PENDING);
+ Runnable rejectBeforeStart = () -> {
+ if (state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) {
+ scheduleSharedMysqlCompensation(player, user, completion, debit);
+ }
+ };
+ try {
+ runPurchaseEntityTask(player, () -> {
+ if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return;
+ try {
+ completePurchase(player, user, item, placeholders, shopData);
+ } catch (RuntimeException | Error rewardFailure) {
+ state.set(COMPLETION_FINISHED);
+ logClaimedRewardSchedulingFailure(debit);
+ // This callback is already running on the player's entity lane. The
+ // claimed journal row must remain for reconciliation because the reward
+ // may have partially executed, but callers must not wait forever.
+ completeClaimedRewardFailure(completion);
+ throw rewardFailure;
+ }
+ try {
+ plugin.getTimer().execute(() -> settleSharedMysqlPurchase(player, completion, debit));
+ } catch (RuntimeException schedulingFailure) {
+ plugin.debug(schedulingFailure);
+ // The reward has already run, so settlement must retain the same
+ // idempotent journal operation even when the persistence executor is
+ // saturated or stopping. Bukkit's async scheduler keeps JDBC off the
+ // entity lane and is independent from that executor.
+ try {
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
+ () -> settleSharedMysqlPurchase(player, completion, debit));
+ } catch (RuntimeException asyncSchedulingFailure) {
+ // A shutdown can reject both schedulers. The reward cannot be run
+ // again, so retain HOOK_STARTED for explicit reconciliation while
+ // still completing the already-successful purchase exactly once.
+ plugin.debug(asyncSchedulingFailure);
+ completeSuccessfulPurchase(player, completion);
+ }
+ } finally {
+ state.set(COMPLETION_FINISHED);
+ }
+ }, rejectBeforeStart);
+ } catch (RuntimeException schedulingFailure) {
+ rejectBeforeStart.run();
+ plugin.debug(schedulingFailure);
+ }
+ }
+
+ private void logClaimedRewardSchedulingFailure(SharedPurchaseDebit debit) {
+ plugin.getLogger().severe("Shared MySQL vote shop purchase " + debit.purchaseId()
+ + " was claimed but its reward callback did not complete; retaining it for reconciliation");
+ }
+
+ /**
+ * Folia provides the entity-retirement signal that decides whether a durable
+ * reservation must be compensated. Preserve that lifecycle when it is
+ * available, but route legacy Bukkit/Paper through the safe entity/global
+ * completion scheduler instead of dereferencing a missing Folia adapter.
+ */
+ private void runPurchaseEntityTask(Player player, Runnable task, Runnable rejected) {
+ if (plugin.getBukkitScheduler().getFoliaLib() == null) {
+ BukkitCompletionScheduler.run(plugin, player, task, rejected);
+ return;
+ }
+ CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl()
+ .runAtEntityWithFallback(player, ignored -> task.run(), rejected);
+ result.whenComplete((status, failure) -> {
+ if (failure != null || status != EntityTaskResult.SUCCESS) rejected.run();
+ });
+ }
+
+ private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user,
+ Consumer completion, SharedPurchaseDebit debit) {
+ try {
+ // The local state CAS proves that neither reward callback can start. Persist
+ // that fence before relying on either remaining scheduler; otherwise a task
+ // accepted by the persistence executor could be lost with HOOK_STARTED
+ // still charged and outside automatic recovery.
+ if (!debit.journal().markCompensating(debit.purchaseId())) {
+ // A terminal row may have been handled by recovery already. Do not
+ // enqueue another scheduler task when this invocation did not obtain
+ // the durable compensation fence.
+ completeFailedPurchase(player, completion);
+ return;
+ }
+ } catch (SQLException markerFailure) {
+ rememberPendingCompensationMarker(plugin, debit.purchaseId());
+ plugin.getLogger().severe("Unable to mark an incomplete vote shop purchase for compensation: "
+ + markerFailure.getClass().getSimpleName());
+ plugin.debug(markerFailure);
+ completeFailedPurchase(player, completion);
+ return;
+ }
+ // This method only runs on a persistence worker or Bukkit's independent
+ // async fallback, so completing the fenced refund here cannot block an
+ // entity lane and needs no second executor admission.
+ refundCompensatingMysqlDebit(user, debit);
+ BukkitCompletionScheduler.run(plugin, player,
+ () -> completion.accept(VoteShopPurchaseResult.FAILED));
+ }
+
+ private void scheduleSharedMysqlCompensation(Player player, VotingPluginUser user,
+ Consumer completion, SharedPurchaseDebit debit) {
+ Runnable compensation = () -> compensateSharedMysqlPurchase(player, user, completion, debit);
+ try {
+ plugin.getTimer().execute(compensation);
+ } catch (RuntimeException persistenceRejected) {
+ plugin.debug(persistenceRejected);
+ try {
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation);
+ } catch (RuntimeException asyncRejected) {
+ plugin.debug(asyncRejected);
+ // Both lifecycle executors are unavailable. Preserve local durable
+ // proof that the reward callback never started so startup recovery can
+ // safely move the otherwise ambiguous HOOK_STARTED row to compensation.
+ rememberPendingCompensationMarker(plugin, debit.purchaseId());
+ completeFailedPurchase(player, completion);
+ }
+ }
+ }
+
+ private void completeFailedPurchase(Player player, Consumer completion) {
+ try {
+ BukkitCompletionScheduler.run(plugin, player,
+ () -> completion.accept(VoteShopPurchaseResult.FAILED));
+ } catch (RuntimeException completionFailure) {
+ plugin.debug(completionFailure);
+ }
+ }
+
+ private void completeClaimedRewardFailure(Consumer completion) {
+ try {
+ completion.accept(VoteShopPurchaseResult.RECONCILIATION_REQUIRED);
+ } catch (RuntimeException completionFailure) {
+ plugin.debug(completionFailure);
+ }
+ }
+
+ private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit) {
+ try {
+ if (debit.journal().refundCompensatingReward(debit.purchaseId())) {
+ refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
+ }
+ } catch (SQLException failure) {
+ // A commit/confirmation failure is indeterminate: the refund transaction
+ // may have committed even though this worker could not observe its terminal
+ // journal state. Drop the affected snapshots before any later cache dump so
+ // a stale debit cannot overwrite a durable refund. Recovery will reconcile
+ // the journal state if the transaction did not commit.
+ refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn());
+ plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ }
+
+ private void settleSharedMysqlPurchase(Player player, Consumer completion,
+ SharedPurchaseDebit debit) {
+ completeSharedMysqlPurchase(debit);
+ completeSuccessfulPurchase(player, completion);
+ }
+
+ private void completeSuccessfulPurchase(Player player, Consumer completion) {
+ try {
+ BukkitCompletionScheduler.run(plugin, player,
+ () -> completion.accept(VoteShopPurchaseResult.SUCCESS));
+ } catch (RuntimeException completionFailure) {
+ plugin.debug(completionFailure);
+ }
+ }
+
+ private HashMap purchasePlaceholders(VoteShopItem item) {
+ HashMap placeholders = new HashMap();
+ placeholders.put("identifier", item.getIdentifierName());
+ placeholders.put("points", String.valueOf(item.getCost()));
+ placeholders.put("limit", String.valueOf(item.getLimit()));
+ placeholders.put("shop", definition.getTitle());
+ return placeholders;
+ }
+
+ private void completePurchase(Player player, VotingPluginUser user, VoteShopItem item,
+ HashMap placeholders, FileConfiguration shopData) {
plugin.getLogger().info("VoteShop: " + user.getPlayerName() + "/" + user.getUUID() + " bought "
+ item.getIdentifier() + " for " + item.getCost());
- plugin.getRewardHandler().giveReward(user, plugin.getShopFile().getData(), item.getRewardsPath(),
+ plugin.getRewardHandler().giveReward(user, shopData, item.getRewardsPath(),
new RewardOptions().setPlaceholders(placeholders));
String purchaseMessage = item.getPurchaseMessage();
@@ -105,15 +482,433 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot
user.sendMessage(PlaceholderUtils.replacePlaceHolder(purchaseMessage, placeholders));
VoteShopPurchaseEvent purchaseEvent = new VoteShopPurchaseEvent(player.getUniqueId(), player.getName(), user,
- item.getIdentifier(), item.getCost());
+ item.getIdentifier(), item.getCost(), false);
Bukkit.getPluginManager().callEvent(purchaseEvent);
+ }
- if (item.getLimit() > 0) {
- user.setVoteShopIdentifierLimit(item.getIdentifier(),
- user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1);
+ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item) {
+ synchronized (purchaseLock(user.getUUID())) {
+ if (usesSharedMysqlPoints()) {
+ return debitSharedMysql(user, item);
+ }
+ if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) {
+ return VoteShopPurchaseResult.LIMIT_REACHED;
+ }
+ if (!user.removePoints(item.getCost(), true)) {
+ return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
+ }
+ if (item.getLimit() > 0) {
+ user.setVoteShopIdentifierLimit(item.getIdentifier(),
+ user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1);
+ }
+ return VoteShopPurchaseResult.SUCCESS;
}
+ }
- return VoteShopPurchaseResult.SUCCESS;
+ private boolean usesSharedMysqlPoints() {
+ return usesSharedMysqlPoints(plugin);
+ }
+
+ /**
+ * A vote-shop limit belongs to the shared MySQL user row even when point
+ * balances are server-suffixed. Limited MySQL purchases must therefore reserve
+ * both the selected points column and the shared limit under the journal epoch
+ * lock; otherwise two servers can independently pass a stale local limit read.
+ */
+ private boolean usesMysqlPurchaseReservation(VoteShopItem item) {
+ return usesSharedMysqlPoints() || plugin != null && item != null && item.getLimit() > 0
+ && UserStorage.MYSQL.equals(plugin.getStorageType());
+ }
+
+ private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) {
+ return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType())
+ && !plugin.getBungeeSettings().isPerServerPoints();
+ }
+
+ /** Existing durable purchases remain recoverable after shared points are disabled. */
+ static boolean canRecoverSharedMysqlPurchases(VotingPluginMain plugin) {
+ return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType());
+ }
+
+ /**
+ * Resets a shared-MySQL vote-shop limit with the durable epoch marker used by
+ * reservations. Other storage modes retain the established UserManager reset.
+ */
+ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitColumn) {
+ resetSharedMysqlLimit(plugin, limitColumn, UUID.randomUUID().toString());
+ }
+
+ /** Applies a named reset at most once across all backends sharing the table. */
+ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitColumn, String resetGeneration) {
+ if (!usesSharedMysqlPoints(plugin)) return;
+ withSharedMysqlCacheResetFence(() -> {
+ // Shared limit writes are deliberately nonqueued. Drop read snapshots
+ // without dumping them, so a backend arriving after another server's reset
+ // can never replay a pre-reset absolute value.
+ SharedMysqlCacheReconciler.invalidateAll(plugin, limitColumn);
+ try {
+ MySQL table = plugin.getMysql();
+ table.checkColumn(limitColumn, DataType.INTEGER);
+ SharedMysqlPurchaseJournal.forTable(table).resetLimit(limitColumn, resetGeneration);
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically reset shared MySQL vote shop limit: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ } finally {
+ SharedMysqlCacheReconciler.invalidateAllAndRefresh(plugin, limitColumn);
+ }
+ });
+ }
+
+ /**
+ * Resets a MySQL limit during a per-server-points transition. Some backends
+ * can still be using shared points, so use the purchase journal rather than
+ * UserManager's independent wipe: {@code resetLimit} locks the epoch row,
+ * wipes precisely this limit column, and publishes the new epoch in one
+ * transaction. Reservations and refunds take that same row lock.
+ */
+ public static boolean resetMysqlLimitWithPurchaseFence(VotingPluginMain plugin, String limitColumn,
+ String resetGeneration) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return false;
+ AtomicBoolean reset = new AtomicBoolean();
+ withSharedMysqlCacheResetFence(() -> {
+ // Do not dump cached absolute values around the direct journal update.
+ SharedMysqlCacheReconciler.invalidateAll(plugin, limitColumn);
+ try {
+ MySQL table = plugin.getMysql();
+ table.checkColumn(limitColumn, DataType.INTEGER);
+ SharedMysqlPurchaseJournal.forTable(table).resetLimit(limitColumn, resetGeneration);
+ reset.set(true);
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to atomically reset MySQL vote shop limit: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ } finally {
+ SharedMysqlCacheReconciler.invalidateAllAndRefresh(plugin, limitColumn);
+ }
+ });
+ return reset.get();
+ }
+
+ static void withSharedMysqlCacheResetFence(Runnable action) {
+ SharedMysqlCacheReconciler.withResetFence(action);
+ }
+
+ static void withSharedMysqlCacheDumpFence(Runnable action) {
+ SharedMysqlCacheReconciler.withCacheDumpFence(action);
+ }
+
+ /** Runs bounded stale-purchase recovery from the plugin lifecycle executor. */
+ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) {
+ if (!canRecoverSharedMysqlPurchases(plugin)) return;
+ try {
+ recoverSharedMysqlPurchases(plugin, SharedMysqlPurchaseJournal.forTable(plugin.getMysql()));
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to recover pending shared MySQL vote shop purchases: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ }
+
+ static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal)
+ throws SQLException {
+ retryPendingCompensationMarkers(plugin, journal);
+ for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) {
+ SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, refund.uuid(), refund.pointsColumn(),
+ refund.limitColumn());
+ }
+ }
+
+ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item) {
+ // This package-visible synchronous helper has no reward lifecycle to settle
+ // later. Keep its conditional debit self-contained; asynchronous purchases
+ // exclusively use reserveSharedMysqlPurchase() below so they can retain a
+ // durable PENDING record until the reward hook is settled or refunded.
+ MySQL table = plugin.getMysql();
+ String pointsColumn = user.getPointsPath();
+ String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
+ drainPurchaseCache(user, pointsColumn);
+ if (limitColumn != null) table.checkColumn(limitColumn, DataType.INTEGER);
+ StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ")
+ .append(table.qi(pointsColumn)).append(" = ").append(table.qi(pointsColumn)).append(" - ?");
+ if (limitColumn != null) {
+ sql.append(", ").append(table.qi(limitColumn)).append(" = COALESCE(")
+ .append(table.qi(limitColumn)).append(", 0) + 1");
+ }
+ sql.append(" WHERE ").append(table.qi("uuid"))
+ .append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?")
+ .append(" AND ").append(table.qi(pointsColumn)).append(" >= ?");
+ if (limitColumn != null) sql.append(" AND COALESCE(").append(table.qi(limitColumn)).append(", 0) < ?");
+
+ boolean debited = false;
+ try (Connection connection = requireConnection(table);
+ PreparedStatement statement = connection.prepareStatement(sql.toString())) {
+ statement.setInt(1, item.getCost());
+ statement.setString(2, user.getUUID());
+ statement.setInt(3, item.getCost());
+ if (limitColumn != null) statement.setInt(4, item.getLimit());
+ debited = statement.executeUpdate() == 1;
+ } catch (SQLException failure) {
+ // JDBC can fail after a server has applied the conditional update. Drop
+ // snapshots recreated during that unknown outcome so a later cache dump
+ // cannot restore the pre-debit values.
+ refreshPurchaseCache(user, pointsColumn, limitColumn);
+ plugin.getLogger().severe("Unable to atomically debit vote shop points: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return VoteShopPurchaseResult.FAILED;
+ }
+ if (debited) {
+ // The conditional debit connection has been closed before NO_CACHE reads.
+ refreshPurchaseCache(user, pointsColumn, limitColumn);
+ return VoteShopPurchaseResult.SUCCESS;
+ }
+ return sharedMysqlFailure(user, item, limitColumn);
+ }
+
+ private static Connection requireConnection(MySQL table) throws SQLException {
+ Connection connection = table.getMysql().getConnectionManager().getConnection();
+ if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection");
+ return connection;
+ }
+
+ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item,
+ LimitGeneration limitGeneration) {
+ MySQL table = plugin.getMysql();
+ String pointsColumn = user.getPointsPath();
+ String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null;
+ drainPurchaseCache(user, pointsColumn);
+ if (limitColumn != null) {
+ table.checkColumn(limitColumn, DataType.INTEGER);
+ }
+ try {
+ SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table);
+ String purchaseId = UUID.randomUUID().toString();
+ if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(),
+ limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) {
+ // reserve() returns only after its transaction and connection are closed;
+ // NO_CACHE reads must not contend with its one-connection pool handle.
+ refreshPurchaseCache(user, pointsColumn, limitColumn);
+ return new SharedPurchaseDebit(VoteShopPurchaseResult.SUCCESS, journal, purchaseId, pointsColumn,
+ limitColumn);
+ }
+ } catch (SQLException failure) {
+ // reserve() can throw after its commit acknowledgement and confirmation
+ // both fail. The debit may therefore be durable even though this caller
+ // reports FAILED; drop snapshots recreated during that transaction so a
+ // later cache dump cannot restore the pre-reservation values.
+ refreshPurchaseCache(user, pointsColumn, limitColumn);
+ plugin.getLogger().severe("Unable to atomically debit vote shop points: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null);
+ }
+ return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null);
+ }
+
+ private void drainPurchaseCache(VotingPluginUser user, String pointsColumn) {
+ withSharedMysqlCacheDumpFence(() -> {
+ if (!user.isCached()) return;
+ UserDataCache cache = user.getCache();
+ if (cache == null) return;
+ synchronized (cache) {
+ // dump() waits for a cache batch that has already left its queue. Strip an
+ // async point prediction first so it cannot be persisted ahead of this debit.
+ SharedMysqlCacheReconciler.discardOptimisticPoint(cache, pointsColumn);
+ cache.dump();
+ plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null);
+ }
+ });
+ }
+
+ private SharedMysqlPurchaseJournal.ClaimOutcome claimSharedMysqlPurchase(SharedPurchaseDebit debit) {
+ try {
+ return debit.journal().claimReward(debit.purchaseId(), System.currentTimeMillis());
+ } catch (SQLException failure) {
+ plugin.getLogger().severe("Unable to claim a pending vote shop purchase: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ return SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE;
+ }
+ }
+
+ private CompletableFuture claimSharedMysqlPurchaseAsync(
+ SharedPurchaseDebit debit) {
+ CompletableFuture result = new CompletableFuture<>();
+ try {
+ plugin.getBukkitScheduler().runTaskAsynchronously(plugin,
+ () -> result.complete(claimSharedMysqlPurchase(debit)));
+ } catch (RuntimeException schedulingFailure) {
+ result.completeExceptionally(schedulingFailure);
+ }
+ return result;
+ }
+
+ private void completeSharedMysqlPurchase(SharedPurchaseDebit debit) {
+ try {
+ debit.journal().complete(debit.purchaseId());
+ } catch (SQLException failure) {
+ // A HOOK_STARTED record is intentionally retained for reconciliation:
+ // the arbitrary reward hook may already have side effects.
+ plugin.getLogger().severe("Unable to settle a completed vote shop purchase: "
+ + failure.getClass().getSimpleName());
+ plugin.debug(failure);
+ }
+ }
+
+ private VoteShopPurchaseResult sharedMysqlFailure(VotingPluginUser user, VoteShopItem item, String limitColumn) {
+ if (limitColumn != null && user.getUserData().getInt(limitColumn, UserDataFetchMode.NO_CACHE) >= item.getLimit()) {
+ return VoteShopPurchaseResult.LIMIT_REACHED;
+ }
+ return VoteShopPurchaseResult.NOT_ENOUGH_POINTS;
+ }
+
+ private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, String limitColumn) {
+ // The shared-MySQL mutation already committed. Invalidate only the fields it
+ // changed; adding absolute values to the cache would turn a concurrent
+ // snapshot into a dirty write that can overwrite another backend's update.
+ SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, user.getUUID(), pointsColumn, limitColumn);
+ }
+
+ private static void rememberPendingCompensationMarker(VotingPluginMain plugin, String purchaseId) {
+ if (plugin == null || purchaseId == null) return;
+ try {
+ compensationStore(plugin).record(purchaseId);
+ } catch (IOException persistenceFailure) {
+ plugin.getLogger().severe("Unable to persist a vote shop compensation marker: "
+ + persistenceFailure.getClass().getSimpleName());
+ plugin.debug(persistenceFailure);
+ }
+ }
+
+ private static void retryPendingCompensationMarkers(VotingPluginMain plugin,
+ SharedMysqlPurchaseJournal journal) {
+ SharedMysqlCompensationStore store = compensationStore(plugin);
+ final java.util.List pending;
+ try {
+ pending = store.loadBatch();
+ } catch (IOException loadFailure) {
+ plugin.debug(loadFailure);
+ return;
+ }
+ for (String purchaseId : pending) {
+ try {
+ journal.markCompensating(purchaseId);
+ store.remove(purchaseId);
+ } catch (SQLException retryFailure) {
+ plugin.debug(retryFailure);
+ } catch (IOException removalFailure) {
+ plugin.debug(removalFailure);
+ }
+ }
+ }
+
+ private static SharedMysqlCompensationStore compensationStore(VotingPluginMain plugin) {
+ return new SharedMysqlCompensationStore(plugin.getDataFolder().toPath());
+ }
+
+ private LimitGeneration limitGeneration(VoteShopItem item, long nowMillis) {
+ if (item.getLimit() <= 0) return LimitGeneration.NONE;
+ return limitGeneration(plugin, item.getIdentifier(), nowMillis);
+ }
+
+ /** Stable identifier shared by every backend processing the same reset period. */
+ public static String currentLimitGenerationId(VotingPluginMain plugin, String identifier) {
+ return limitGeneration(plugin, identifier, System.currentTimeMillis()).value();
+ }
+
+ private static LimitGeneration limitGeneration(VotingPluginMain plugin, String identifier, long nowMillis) {
+ boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier);
+ boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier);
+ boolean monthly = plugin.getShopFile().getVoteShopResetMonthly(identifier);
+ ZoneId timeZone = configuredTimeZone(plugin);
+ int hourOffset = plugin.getOptions().getTimeHourOffSet();
+ LocalDateTime current = networkCurrentTime(nowMillis, timeZone, hourOffset);
+ return limitGeneration(current, nowMillis, daily, weekly, monthly,
+ plugin.getOptions().getTimeWeekOffSet(), timeZone, hourOffset);
+ }
+
+ private static ZoneId configuredTimeZone(VotingPluginMain plugin) {
+ return networkTimeZone(plugin.getOptions().getTimeZone());
+ }
+
+ static ZoneId networkTimeZone(String configured) {
+ if (configured == null || configured.isBlank()) return ZoneId.of("UTC");
+ try {
+ return ZoneId.of(configured);
+ } catch (RuntimeException invalidZone) {
+ return ZoneId.of("UTC");
+ }
+ }
+
+ static LocalDateTime networkCurrentTime(long nowMillis, ZoneId timeZone, int hourOffset) {
+ return LocalDateTime.ofInstant(Instant.ofEpochMilli(nowMillis), timeZone).plusHours(hourOffset);
+ }
+
+ static LimitGeneration limitGeneration(LocalDateTime current, long nowMillis, boolean daily, boolean weekly,
+ boolean monthly, int weekOffset) {
+ return limitGeneration(current, nowMillis, daily, weekly, monthly, weekOffset, ZoneId.of("UTC"), 0);
+ }
+
+ private static LimitGeneration limitGeneration(LocalDateTime current, long nowMillis, boolean daily, boolean weekly,
+ boolean monthly, int weekOffset, ZoneId timeZone, int hourOffset) {
+ if (!daily && !weekly && !monthly) return LimitGeneration.NONE;
+ LocalDateTime next = null;
+ StringBuilder generation = new StringBuilder();
+ if (daily) {
+ next = current.toLocalDate().plusDays(1).atStartOfDay();
+ generation.append("D:").append(current.toLocalDate());
+ }
+ if (weekly) {
+ LocalDateTime weekBoundary = current.toLocalDate().plusDays(1).atStartOfDay();
+ int week = networkWeekNumber(current, weekOffset);
+ while (networkWeekNumber(weekBoundary, weekOffset) == week) {
+ weekBoundary = weekBoundary.plusDays(1);
+ }
+ if (next == null || weekBoundary.isBefore(next)) next = weekBoundary;
+ if (generation.length() > 0) generation.append('|');
+ generation.append(weeklyGenerationId(current, weekOffset));
+ }
+ if (monthly) {
+ LocalDateTime monthBoundary = current.toLocalDate().withDayOfMonth(1).plusMonths(1).atStartOfDay();
+ if (next == null || monthBoundary.isBefore(next)) next = monthBoundary;
+ if (generation.length() > 0) generation.append('|');
+ generation.append("M:").append(current.getYear()).append('-').append(current.getMonthValue());
+ }
+ long expiresAt = next.minusHours(hourOffset).atZone(timeZone).toInstant().toEpochMilli();
+ if (expiresAt <= nowMillis) expiresAt = nowMillis + Math.max(1L, Duration.between(current, next).toMillis());
+ return new LimitGeneration(generation.toString(), expiresAt);
+ }
+
+ static String weeklyGenerationId(LocalDateTime current, int weekOffset) {
+ LocalDateTime weekTime = current.plusDays(weekOffset);
+ return "W:" + weekTime.get(NETWORK_WEEK_FIELDS.weekBasedYear()) + '-'
+ + weekTime.get(NETWORK_WEEK_FIELDS.weekOfWeekBasedYear());
+ }
+
+ private static int networkWeekNumber(LocalDateTime time, int weekOffset) {
+ return time.plusDays(weekOffset).get(NETWORK_WEEK_FIELDS.weekOfWeekBasedYear());
+ }
+
+ record SharedPurchaseDebit(VoteShopPurchaseResult result, SharedMysqlPurchaseJournal journal,
+ String purchaseId, String pointsColumn, String limitColumn) {
+ }
+
+ record LimitGeneration(String value, long expiresAt) {
+ private static final LimitGeneration NONE = new LimitGeneration(
+ SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION, 0L);
+ }
+
+ Object purchaseLock(String uuid) {
+ return PURCHASE_LOCKS[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)];
+ }
+
+ private static Object[] createPurchaseLocks() {
+ Object[] locks = new Object[PURCHASE_LOCK_STRIPES];
+ for (int i = 0; i < locks.length; i++) {
+ locks[i] = new Object();
+ }
+ return locks;
}
/**
@@ -145,6 +940,20 @@ public boolean hasPermission(Player player, String permission) {
*/
public void sendFailureMessage(Player player, VotingPluginUser user, VoteShopItem item,
VoteShopPurchaseResult result) {
+ if (result == VoteShopPurchaseResult.SHOP_DISABLED) {
+ player.sendMessage(com.bencodez.simpleapi.messages.MessageAPI.colorize("&cVote shop disabled"));
+ return;
+ }
+ if (result == VoteShopPurchaseResult.FAILED) {
+ player.sendMessage(com.bencodez.simpleapi.messages.MessageAPI.colorize(
+ "&cUnable to complete this purchase; please try again."));
+ return;
+ }
+ if (result == VoteShopPurchaseResult.RECONCILIATION_REQUIRED) {
+ player.sendMessage(com.bencodez.simpleapi.messages.MessageAPI.colorize(
+ "&cThis purchase requires administrator review; do not retry it."));
+ return;
+ }
if (result == VoteShopPurchaseResult.LIMIT_REACHED) {
user.sendMessage(definition.getLimitReachedMessage());
return;
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java
new file mode 100644
index 0000000000..77f85e4a19
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java
@@ -0,0 +1,131 @@
+package com.bencodez.votingplugin.commands;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import org.bukkit.command.CommandSender;
+import org.bukkit.entity.Player;
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.simpleapi.scheduler.BukkitScheduler;
+import com.bencodez.simpleapi.folialib.FoliaLib;
+import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;
+import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.config.Config;
+import com.bencodez.votingplugin.user.PointTransferResult;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+import com.bencodez.votingplugin.util.BukkitCompletionScheduler;
+
+class CommandLoaderSchedulingTest {
+ @Test
+ void playerCommandCompletionUsesTheSendersEntityLane() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ configureEntityScheduler(scheduler);
+ Player sender = mock(Player.class);
+ Runnable completion = () -> { };
+
+ new CommandLoader(plugin).runForCommandSender(sender, completion);
+
+ verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(sender));
+ verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class));
+ }
+
+ @Test
+ void consoleCommandCompletionUsesTheGlobalLane() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ configureEntityScheduler(scheduler);
+ CommandSender sender = mock(CommandSender.class);
+ Runnable completion = () -> { };
+
+ new CommandLoader(plugin).runForCommandSender(sender, completion);
+
+ verify(scheduler).runTask(eq(plugin), any(Runnable.class));
+ verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class), any(Player.class));
+ }
+
+ @Test
+ void onlineVotingUserCompletionUsesTheRecipientsEntityLane() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ configureEntityScheduler(scheduler);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ Player recipient = mock(Player.class);
+ when(user.getPlayer()).thenReturn(recipient);
+ Runnable completion = () -> { };
+
+ new CommandLoader(plugin).runForVotingUser(user, completion);
+
+ verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(recipient));
+ verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class));
+ }
+
+ @Test
+ void offlineVotingUserCompletionUsesTheGlobalLane() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ Runnable completion = () -> { };
+
+ new CommandLoader(plugin).runForVotingUser(user, completion);
+
+ verify(scheduler).runTask(eq(plugin), any(Runnable.class));
+ verify(scheduler, never()).runTask(eq(plugin), any(Runnable.class), any(Player.class));
+ }
+
+ @Test
+ void transferFailureMessagesDoNotDiagnoseAvailabilityAsInsufficientPoints() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ Config config = mock(Config.class);
+ when(plugin.getConfigFile()).thenReturn(config);
+ when(config.getFormatCommandsVoteGivePointsNotEnoughPoints()).thenReturn("insufficient");
+ when(config.getFormatCommandsVoteGivePointsUnavailable()).thenReturn("retry");
+ when(config.getFormatCommandsVoteGivePointsPendingConfirmation()).thenReturn("pending; do not retry");
+ CommandLoader loader = new CommandLoader(plugin);
+
+ org.junit.jupiter.api.Assertions.assertEquals("insufficient",
+ loader.transferFailureMessage(PointTransferResult.INSUFFICIENT_POINTS));
+ org.junit.jupiter.api.Assertions.assertEquals("retry", loader.transferFailureMessage(PointTransferResult.CANCELLED));
+ org.junit.jupiter.api.Assertions.assertEquals("retry", loader.transferFailureMessage(PointTransferResult.UNAVAILABLE));
+ org.junit.jupiter.api.Assertions.assertEquals("pending; do not retry",
+ loader.transferFailureMessage(PointTransferResult.PENDING_CONFIRMATION));
+ }
+
+ @Test
+ void durableClaimRecoveryRunsOnlyWhenTheGlobalSchedulerRejectsBeforeTaskStart() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ BukkitScheduler scheduler = mock(BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ doThrow(new IllegalStateException("stopping")).when(scheduler).runTask(eq(plugin), any(Runnable.class));
+ AtomicBoolean taskRan = new AtomicBoolean();
+ AtomicBoolean rejected = new AtomicBoolean();
+
+ BukkitCompletionScheduler.run(plugin, null, () -> taskRan.set(true), () -> rejected.set(true));
+
+ org.junit.jupiter.api.Assertions.assertFalse(taskRan.get());
+ org.junit.jupiter.api.Assertions.assertTrue(rejected.get());
+ }
+
+ private static void configureEntityScheduler(BukkitScheduler scheduler) {
+ FoliaLib folia = mock(FoliaLib.class);
+ ServerImplementation entityScheduler = mock(ServerImplementation.class);
+ when(scheduler.getFoliaLib()).thenReturn(folia);
+ when(folia.getImpl()).thenReturn(entityScheduler);
+ when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class)))
+ .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS));
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java
new file mode 100644
index 0000000000..55ff3d3406
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java
@@ -0,0 +1,16 @@
+package com.bencodez.votingplugin.commands.gui.player;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+class VoteShopConfirmTest {
+ @Test
+ void confirmationCanSubmitOnlyOnePurchase() {
+ VoteShopConfirm confirmation = new VoteShopConfirm(null, null, null, null, null);
+
+ assertTrue(confirmation.beginPurchase());
+ assertFalse(confirmation.beginPurchase());
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java
index ce30fc1890..babff77a74 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java
@@ -860,9 +860,14 @@ class BackendConfigurationServiceTest {
Files.writeString(directory.resolve("SpecialRewards.yml"), "VoteParty:\n Enabled: false\n");
BackendConfigurationService.QuickPreview party = service.previewQuickSetup("vote-party", Map.of(
- "votesRequired", "25", "command", "give %player% diamond 1", "broadcast", "Party!",
+ "enabled", "false", "votesRequired", "25", "command", "give %player% diamond 1", "broadcast", "Party!",
"giveAllPlayers", "false", "onlineOnly", "true"));
assertTrue(party.proposal().content().contains("VotesRequired: 25"));
+ assertTrue(party.proposal().content().contains("Enabled: false"));
+ BackendConfigurationService.QuickPreview legacyParty = service.previewQuickSetup("vote-party", Map.of(
+ "votesRequired", "25", "command", "", "broadcast", "",
+ "giveAllPlayers", "false", "onlineOnly", "true"));
+ assertTrue(legacyParty.proposal().content().contains("Enabled: false"));
}
@Test void guidedSettingsReadTheInstalledValuesInsteadOfAssumingDefaults() throws Exception {
@@ -886,6 +891,10 @@ class BackendConfigurationServiceTest {
assertEquals("EMERALD", service.readQuickSetup("vote-site", Map.of("name", "PMC"))
.options().get("material"));
assertEquals("2", service.readQuickSetup("vote-party", Map.of()).options().get("rewardCommandCount"));
+ assertEquals("true", service.readQuickSetup("vote-party", Map.of("enabled", "false"))
+ .options().get("enabled"));
+ assertThrows(IllegalArgumentException.class,
+ () -> service.readQuickSetup("vote-party", Map.of("enabled", "not-a-boolean")));
}
@Test void oversizedInstalledGuidedValuesFailInsteadOfWedgingResultSubmission() throws Exception {
@@ -966,7 +975,7 @@ class BackendConfigurationServiceTest {
assertFalse(reward.proposal().content().contains("New message"));
BackendConfigurationService.QuickPreview party = service.previewQuickSetup("vote-party", Map.of(
- "votesRequired", "20", "command", "new party", "broadcast", "Party!",
+ "enabled", "true", "votesRequired", "20", "command", "new party", "broadcast", "Party!",
"giveAllPlayers", "false", "onlineOnly", "true"));
assertTrue(party.proposal().content().contains("existing party"));
assertTrue(party.proposal().content().contains("new party"));
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java
index ca9d1e9428..11c607db9b 100644
--- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java
@@ -214,6 +214,8 @@ class BackendControlConnectorProtocolTest {
.anyMatch(value -> "config.file-comments.v1".equals(value.getAsString())));
assertTrue(advertised.asList().stream()
.anyMatch(value -> "config.vote-sites-sync.v1".equals(value.getAsString())));
+ assertTrue(advertised.asList().stream()
+ .anyMatch(value -> "config.quick-setup.v2".equals(value.getAsString())));
assertTrue(advertised.asList().stream()
.anyMatch(value -> "config.reward-files.v1".equals(value.getAsString())));
assertTrue(advertised.asList().stream()
@@ -258,10 +260,32 @@ class BackendControlConnectorProtocolTest {
}
@Test void voteSitesSyncRequiresBothNegotiatedCapabilities() {
- assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false));
- assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, true));
- assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, true));
- assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false));
+ assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false, false));
+ assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", false, false, true));
+ assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("sync-vote-sites", true, false, true));
+ assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("common-settings", true, false, false));
+ }
+
+ @Test void votePartyRequiresItsVersionedCapability() {
+ assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false));
+ assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false));
+ assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false));
+ }
+
+ @Test void legacyVotePartyOptionsUseV1ButEnabledRequiresV2() {
+ assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false,
+ Map.of("threshold", "10")));
+ assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false,
+ Map.of("enabled", "true")));
+ assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false,
+ Map.of("enabled", "true")));
+ assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false,
+ Map.of("enabled", "true")));
+ Map state = Map.of("enabled", "false", "votesRequired", "20");
+ assertEquals(Map.of("votesRequired", "20"),
+ BackendControlConnector.resultQuickReadOptions("vote-party", state, Map.of()));
+ assertEquals(state, BackendControlConnector.resultQuickReadOptions("vote-party", state,
+ Map.of("enabled", "false")));
}
@Test void rewardBuilderResultsKeepOnlyTheSafeRecoveryTarget() {
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java
new file mode 100644
index 0000000000..7df46ed239
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java
@@ -0,0 +1,22 @@
+package com.bencodez.votingplugin.events;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.votingplugin.user.VotingPluginUser;
+
+class VoteShopPurchaseEventTest {
+ @Test
+ void entityLanePurchaseCanBeMarkedSynchronousWithoutChangingTheLegacyConstructor() {
+ UUID uuid = UUID.randomUUID();
+ VotingPluginUser user = mock(VotingPluginUser.class);
+
+ assertFalse(new VoteShopPurchaseEvent(uuid, "voter", user, "reward", 5, false).isAsynchronous());
+ assertTrue(new VoteShopPurchaseEvent(uuid, "voter", user, "reward", 5).isAsynchronous());
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java
new file mode 100644
index 0000000000..d4abd11339
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java
@@ -0,0 +1,97 @@
+package com.bencodez.votingplugin.rewards.builtin;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.rewards.Reward;
+import com.bencodez.advancedcore.api.user.AdvancedCoreUser;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.user.UserManager;
+import com.bencodez.votingplugin.user.VotingPluginUser;
+
+class RewardPointsTest {
+ @Test
+ void publishesStorageAwarePointTotalWithoutBlockingTheRewardLane() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager manager = mock(UserManager.class);
+ AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(plugin.getVotingPluginUserManager()).thenReturn(manager);
+ when(manager.getVotingPluginUser(advancedUser)).thenReturn(user);
+ when(user.addPointsStorageAware(5)).thenReturn(73);
+
+ String result = new RewardPoints(plugin).onRewardRequest(mock(Reward.class), advancedUser, 5,
+ new HashMap<>());
+
+ assertEquals("73", result);
+ verify(user).addPointsStorageAware(5);
+ verify(user, never()).addPoints(5);
+ }
+
+ @Test
+ void asyncRewardWaitsForCommittedPointTotal() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager manager = mock(UserManager.class);
+ AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ CompletableFuture committed = new CompletableFuture<>();
+ when(plugin.getVotingPluginUserManager()).thenReturn(manager);
+ when(manager.getVotingPluginUser(advancedUser)).thenReturn(user);
+ when(user.addPointsStorageAwareAsync(5)).thenReturn(committed);
+ RewardPoints points = new RewardPoints(plugin);
+
+ CompletableFuture result = points.onRewardRequestAsync(mock(Reward.class), advancedUser, 5,
+ new HashMap<>()).toCompletableFuture();
+
+ assertEquals(false, result.isDone());
+ committed.complete(73);
+ assertEquals("73", result.join());
+ verify(user).addPointsStorageAwareAsync(5);
+ verify(user, never()).addPointsStorageAware(5);
+ }
+
+ @Test
+ void asyncRewardPropagatesPersistenceFailure() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager manager = mock(UserManager.class);
+ AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(plugin.getVotingPluginUserManager()).thenReturn(manager);
+ when(manager.getVotingPluginUser(advancedUser)).thenReturn(user);
+ when(user.addPointsStorageAwareAsync(5)).thenReturn(
+ CompletableFuture.failedFuture(new IllegalStateException("write failed")));
+
+ CompletableFuture result = new RewardPoints(plugin)
+ .onRewardRequestAsync(mock(Reward.class), advancedUser, 5, new HashMap<>()).toCompletableFuture();
+
+ org.junit.jupiter.api.Assertions.assertThrows(CompletionException.class, result::join);
+ }
+
+ @Test
+ void durableReplayCheckpointAcknowledgesTheMatchingPointOperation() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class);
+ UserManager manager = mock(UserManager.class);
+ AdvancedCoreUser advancedUser = mock(AdvancedCoreUser.class);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(plugin.getVotingPluginUserManager()).thenReturn(manager);
+ when(manager.getVotingPluginUser(advancedUser)).thenReturn(user);
+ when(user.getUUID()).thenReturn("player-uuid");
+ when(user.acknowledgeStorageAwarePointOperation(org.mockito.ArgumentMatchers.anyString()))
+ .thenReturn(CompletableFuture.completedFuture(null));
+
+ new RewardPoints(plugin).onReplayCheckpointPersisted(mock(Reward.class), advancedUser, "occurrence-1",
+ "AsyncReward/0").toCompletableFuture().join();
+
+ verify(user).acknowledgeStorageAwarePointOperation(
+ "1d257d984bf6531c07e366b02a5373043961a6e44c23528f91d027c0d2c83f64");
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java
new file mode 100644
index 0000000000..399f2e19d9
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java
@@ -0,0 +1,40 @@
+package com.bencodez.votingplugin.user;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.HashMap;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.votingplugin.VotingPluginMain;
+
+class SharedMysqlCacheReconcilerTest {
+ @Test
+ void invalidateAllCopiesTheLiveRegistryBeforeWalkingCaches() {
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("VoteShopLimitdaily", mock(DataValue.class));
+ when(cache.getCache()).thenReturn(values);
+
+ ConcurrentHashMap liveCaches = new ConcurrentHashMap<>() {
+ @Override
+ public java.util.Collection values() {
+ throw new AssertionError("reset invalidation must not walk the live values view");
+ }
+ };
+ liveCaches.put(uuid, cache);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(liveCaches);
+
+ SharedMysqlCacheReconciler.invalidateAll(plugin, "VoteShopLimitdaily");
+
+ assertFalse(values.containsKey("VoteShopLimitdaily"));
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java
new file mode 100644
index 0000000000..6653187f8d
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java
@@ -0,0 +1,778 @@
+package com.bencodez.votingplugin.user;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doCallRealMethod;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.ArgumentMatchers.any;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.lang.reflect.Method;
+import java.util.HashMap;
+import java.util.UUID;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.UserData;
+import com.bencodez.advancedcore.api.user.UserDataFetchMode;
+import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+import com.bencodez.simpleapi.sql.data.DataValue;
+import com.bencodez.simpleapi.sql.data.DataValueInt;
+import com.bencodez.votingplugin.VotingPluginMain;
+
+class SharedMysqlPointMutatorTest {
+ @Test
+ void transferApprovalUsesLegacyEntitySchedulerWhenFoliaIsUnavailable() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
+ mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ when(scheduler.getFoliaLib()).thenReturn(null);
+ when(plugin.getTimer()).thenReturn(persistence);
+ when(plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>());
+ doAnswer(invocation -> {
+ invocation.getArgument(1, Runnable.class).run();
+ return null;
+ }).when(scheduler).runTask(eq(plugin), any(Runnable.class), any(org.bukkit.entity.Player.class));
+ doAnswer(invocation -> {
+ invocation.getArgument(0, Runnable.class).run();
+ return null;
+ }).when(persistence).execute(any(Runnable.class));
+
+ org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class);
+ VotingPluginUser source = mock(VotingPluginUser.class);
+ when(source.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(source.getPointsPath()).thenReturn("Points_server_a");
+ when(source.getPlayer()).thenReturn(player);
+ VotingPluginUser target = mock(VotingPluginUser.class);
+ when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
+ when(target.getPointsPath()).thenReturn("Points_server_b");
+ when(target.getPlayer()).thenReturn(player);
+ SharedPointTransferJournal journal = mock(SharedPointTransferJournal.class);
+ when(journal.claimHookWithConfirmation(eq("transfer-1"), eq("owner"), org.mockito.ArgumentMatchers.anyLong()))
+ .thenReturn(SharedPointTransferJournal.ClaimOutcome.CLAIMED);
+ when(journal.settleWithConfirmation(eq("transfer-1"), eq("owner"), anyString(), anyString(), anyString(),
+ anyString(), eq(10), eq(10))).thenReturn(SharedPointTransferJournal.SettlementOutcome.COMPLETED);
+ AtomicReference completion = new AtomicReference<>();
+ java.util.function.Consumer resultConsumer = completion::set;
+
+ Method claim = SharedMysqlPointMutator.class.getDeclaredMethod("claimTransferForApproval",
+ VotingPluginUser.class, VotingPluginUser.class, org.bukkit.entity.Player.class,
+ org.bukkit.entity.Player.class, int.class, java.util.function.IntFunction.class,
+ java.util.function.Consumer.class, SharedPointTransferJournal.class, String.class, String.class, String.class,
+ String.class);
+ claim.setAccessible(true);
+ claim.invoke(new SharedMysqlPointMutator(plugin), source, target, player, player, 10,
+ (java.util.function.IntFunction) value -> value, resultConsumer, journal, "transfer-1", "owner",
+ "Points_server_a", "Points_server_b");
+
+ assertEquals(PointTransferResult.SUCCESS, completion.get());
+ verify(scheduler, times(2)).runTask(eq(plugin), any(Runnable.class), eq(player));
+ }
+
+ @Test
+ void pointMutationDumpHoldsTheSharedLimitResetFence() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeUpdate()).thenReturn(1);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ when(user.isCached()).thenReturn(true);
+ UserDataCache cache = mock(UserDataCache.class);
+ when(user.getCache()).thenReturn(cache);
+
+ java.util.concurrent.CountDownLatch dumpEntered = new java.util.concurrent.CountDownLatch(1);
+ java.util.concurrent.CountDownLatch releaseDump = new java.util.concurrent.CountDownLatch(1);
+ java.util.concurrent.CountDownLatch resetEntered = new java.util.concurrent.CountDownLatch(1);
+ org.mockito.Mockito.doAnswer(invocation -> {
+ dumpEntered.countDown();
+ assertTrue(releaseDump.await(2, TimeUnit.SECONDS));
+ return null;
+ }).when(cache).dump();
+ java.util.concurrent.ExecutorService workers = java.util.concurrent.Executors.newFixedThreadPool(2);
+ try {
+ java.util.concurrent.Future mutation = workers.submit(
+ () -> new SharedMysqlPointMutator(plugin).setCommitted(user, 20));
+ assertTrue(dumpEntered.await(1, TimeUnit.SECONDS));
+ java.util.concurrent.Future> reset = workers.submit(
+ () -> SharedMysqlCacheReconciler.withResetFence(resetEntered::countDown));
+
+ assertFalse(resetEntered.await(100, TimeUnit.MILLISECONDS),
+ "a limit reset must wait for an in-flight point-mutation cache dump");
+ releaseDump.countDown();
+ assertTrue(mutation.get(1, TimeUnit.SECONDS));
+ reset.get(1, TimeUnit.SECONDS);
+ assertEquals(0, resetEntered.getCount());
+ } finally {
+ releaseDump.countDown();
+ workers.shutdownNow();
+ }
+ }
+
+ @Test
+ void indeterminateTransferReservationInvalidatesRecreatedSourcePoints() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(null);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
+ mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ org.mockito.Mockito.doAnswer(invocation -> {
+ invocation.getArgument(1, Runnable.class).run();
+ return null;
+ }).when(scheduler).runTask(eq(plugin), any(Runnable.class));
+
+ String sourceUuid = "00000000-0000-0000-0000-000000000001";
+ VotingPluginUser source = mock(VotingPluginUser.class);
+ when(source.getUUID()).thenReturn(sourceUuid);
+ when(source.getPointsPath()).thenReturn("Points");
+ VotingPluginUser target = mock(VotingPluginUser.class);
+ when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
+ when(target.getPointsPath()).thenReturn("Points");
+ UserDataCache recreatedCache = mock(UserDataCache.class);
+ HashMap recreatedValues = new HashMap<>();
+ recreatedValues.put("Points", new DataValueInt(20));
+ recreatedValues.put("DailyTotal", new DataValueInt(4));
+ when(recreatedCache.getCache()).thenReturn(recreatedValues);
+ when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(
+ new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(UUID.fromString(sourceUuid), recreatedCache)));
+ AtomicReference result = new AtomicReference<>();
+
+ new SharedMysqlPointMutator(plugin).transferWithBukkitApproval(source, target, 10, value -> value, result::set);
+ ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class);
+ verify(persistence).execute(reservation.capture());
+ reservation.getValue().run();
+
+ assertFalse(recreatedValues.containsKey("Points"));
+ assertTrue(recreatedValues.containsKey("DailyTotal"));
+ assertEquals(PointTransferResult.UNAVAILABLE, result.get());
+ }
+
+ @Test
+ void indeterminateClaimedRefundStillInvalidatesSourcePoints() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler =
+ mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class);
+ when(plugin.getBukkitScheduler()).thenReturn(scheduler);
+ org.mockito.Mockito.doAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ }).when(scheduler).runTask(eq(plugin), any(Runnable.class));
+ VotingPluginUser source = mock(VotingPluginUser.class);
+ String sourceUuid = "00000000-0000-0000-0000-000000000001";
+ when(source.getUUID()).thenReturn(sourceUuid);
+ when(source.getPointsPath()).thenReturn("Points");
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(10));
+ when(cache.getCache()).thenReturn(values);
+ when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(
+ new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(
+ java.util.UUID.fromString(sourceUuid), cache)));
+ SharedPointTransferJournal journal = mock(SharedPointTransferJournal.class);
+ when(journal.refundHookStarted("transfer-1", source.getUUID(), "Points", 10))
+ .thenThrow(new java.sql.SQLException("lost acknowledgement and confirmation"));
+ AtomicReference result = new AtomicReference<>();
+
+ new SharedMysqlPointMutator(plugin).refundClaimedAfterSchedulingFailure(source, null, result::set, journal,
+ "transfer-1", "Points", 10, new RejectedExecutionException("worker stopped"));
+
+ assertFalse(values.containsKey("Points"));
+ assertEquals(PointTransferResult.UNAVAILABLE, result.get());
+ }
+
+ @Test
+ void recoveryInvalidatesOnlyRefundedColumnsAfterJdbcCompletes() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("Points", mock(DataValue.class));
+ values.put("VoteShopLimitdaily", mock(DataValue.class));
+ values.put("DailyTotal", mock(DataValue.class));
+ when(plugin.getUserManager().getDataManager().getUserDataCache())
+ .thenReturn(new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(
+ java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), cache)));
+ when(cache.getCache()).thenReturn(values);
+
+ SharedMysqlCacheReconciler.invalidate(plugin, "00000000-0000-0000-0000-000000000001", "Points",
+ "VoteShopLimitdaily");
+
+ assertFalse(values.containsKey("Points"));
+ assertFalse(values.containsKey("VoteShopLimitdaily"));
+ assertTrue(values.containsKey("DailyTotal"));
+ }
+
+ @Test
+ void userManagerSchedulesOneBoundedSharedTransferRecoveryPerLifecycle() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false);
+ when(plugin.getTimer()).thenReturn(persistence);
+
+ UserManager manager = new UserManager(plugin);
+ manager.startSharedPointTransferRecovery();
+ manager.startSharedPointTransferRecovery();
+
+ verify(persistence).execute(any(Runnable.class));
+ verify(persistence).scheduleWithFixedDelay(any(Runnable.class), org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+ }
+
+ @Test
+ void userManagerSchedulesRecoveryAtStartupEvenWithPerServerPoints() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ when(plugin.getTimer()).thenReturn(persistence);
+
+ UserManager manager = new UserManager(plugin);
+ manager.startSharedPointTransferRecovery(); // Old shared-point rows still need recovery.
+ manager.startSharedPointTransferRecovery(); // Later reload must not duplicate lifecycle work.
+
+ verify(persistence, times(1)).execute(any(Runnable.class));
+ verify(persistence, times(1)).scheduleWithFixedDelay(any(Runnable.class),
+ org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+ }
+
+ @Test
+ void transferRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+
+ assertTrue(SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin));
+ assertFalse(SharedMysqlPointMutator.usesSharedMysqlPoints(plugin));
+ }
+
+ @Test
+ void pointAdditionAcknowledgementSurvivesPerServerModeSwitch() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql =
+ mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ when(plugin.getTimer()).thenReturn(persistence);
+ when(plugin.getMysql()).thenReturn(table);
+ when(table.getTableName()).thenReturn("VotingPlugin_Ack_Mode_Switch");
+ when(table.qi(anyString())).thenAnswer(call -> "`" + call.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+
+ CompletableFuture completion = new SharedMysqlPointMutator(plugin)
+ .acknowledgePointAddition("reward-operation").toCompletableFuture();
+ assertFalse(completion.isDone());
+ ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class);
+ verify(persistence).execute(work.capture());
+ work.getValue().run();
+ completion.join();
+
+ verify(statement).setString(1, "ACKNOWLEDGED");
+ verify(statement).setString(3, "reward-operation");
+ verify(statement).setString(4, "COMPLETED");
+ }
+
+ @Test
+ void rejectedRecoverySchedulingCanRetryLater() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false);
+ when(plugin.getTimer()).thenReturn(persistence);
+ doThrow(new RejectedExecutionException("stopping")).doNothing()
+ .when(persistence).execute(any(Runnable.class));
+
+ UserManager manager = new UserManager(plugin);
+ manager.startSharedPointTransferRecovery();
+ manager.startSharedPointTransferRecovery();
+
+ verify(persistence, times(2)).execute(any(Runnable.class));
+ verify(persistence).scheduleWithFixedDelay(any(Runnable.class), org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+ }
+
+ @Test
+ void scheduledRecoveryLogsRuntimeFailureWithoutCancellingFixedDelayTask() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ when(plugin.getStorageType()).thenThrow(new IllegalStateException("storage unavailable"));
+
+ SharedMysqlPointMutator.scheduleTransferRecovery(plugin);
+
+ ArgumentCaptor scheduled = ArgumentCaptor.forClass(Runnable.class);
+ verify(persistence).scheduleWithFixedDelay(scheduled.capture(), org.mockito.ArgumentMatchers.eq(1L),
+ org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES));
+ org.junit.jupiter.api.Assertions.assertDoesNotThrow(scheduled.getValue()::run);
+ verify(plugin.getLogger()).severe("Unable to recover shared MySQL point journals: IllegalStateException");
+ }
+
+ @Test
+ void removeReportsARejectedConditionalDebit() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeUpdate()).thenReturn(0);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+
+ assertFalse(new SharedMysqlPointMutator(plugin).remove(user, 10));
+ }
+
+ @Test
+ void pointMutationToleratesCacheRemovalDuringDatabaseWrite() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeUpdate()).thenReturn(1);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ when(user.isCached()).thenReturn(false, true);
+
+ assertTrue(new SharedMysqlPointMutator(plugin).remove(user, 10));
+
+ verify(statement).executeUpdate();
+ verify(plugin.getUserManager().getDataManager(), never()).removeCache(any(), any());
+ }
+
+ @Test
+ void asynchronousRemoveDoesNotAcquireJdbcOnTheCallerThread() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ UserData data = mock(UserData.class);
+ when(user.getUserData()).thenReturn(data);
+ when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(20);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+
+ assertTrue(new SharedMysqlPointMutator(plugin).remove(user, 10, true));
+ ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class);
+ verify(persistence).execute(work.capture());
+ verify(sql.getConnectionManager(), never()).getConnection();
+ verify(data).getInt("Points", UserDataFetchMode.TEMP_ONLY);
+
+ work.getValue().run();
+ verify(sql.getConnectionManager()).getConnection();
+ verify(statement).executeUpdate();
+ }
+
+ @Test
+ void asynchronousAddUsesOnlyCachedPointsOnTheCallerThread() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ UserDataCache cache = mock(UserDataCache.class);
+ DataValue points = mock(DataValue.class);
+ java.util.HashMap values = new java.util.HashMap<>();
+ values.put("Points", points);
+ when(user.getCache()).thenReturn(cache);
+ when(user.isCached()).thenReturn(true);
+ when(cache.getCache()).thenReturn(values);
+ when(points.isInt()).thenReturn(true);
+ when(points.getInt()).thenReturn(20);
+ when(user.getPointsPath()).thenReturn("Points");
+
+ assertEquals(30, new SharedMysqlPointMutator(plugin).add(user, 10, true));
+
+ verify(cache, times(3)).getCache();
+ assertEquals(30, values.get("Points").getInt());
+ verify(user, never()).getPoints();
+ verify(persistence).execute(any(Runnable.class));
+ }
+
+ @Test
+ void asynchronousAddDoesNotFlushItsOptimisticPointsPrediction() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ when(statement.executeUpdate()).thenReturn(1);
+
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ when(user.isCached()).thenReturn(true);
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(20));
+ values.put("DailyTotal", new DataValueInt(4));
+ when(user.getCache()).thenReturn(cache);
+ when(cache.getCache()).thenReturn(values);
+ org.mockito.Mockito.doAnswer(invocation -> {
+ assertFalse(values.containsKey("Points"), "the predicted value must not be persisted by dump");
+ assertTrue(values.containsKey("DailyTotal"), "unrelated pending values must still be flushed");
+ return null;
+ }).when(cache).dump();
+
+ assertEquals(30, new SharedMysqlPointMutator(plugin).add(user, 10, true));
+ assertEquals(30, values.get("Points").getInt());
+ ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class);
+ verify(persistence).execute(task.capture());
+
+ task.getValue().run();
+
+ verify(cache).dump();
+ verify(statement).executeUpdate();
+ }
+
+ @Test
+ void clearingAnOfflineUserCacheCannotFlushAnOptimisticPointsPrediction() {
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ DataValue prediction = new DataValueInt(30);
+ values.put("Points", prediction);
+ when(user.isCached()).thenReturn(true);
+ when(user.getCache()).thenReturn(cache);
+ when(user.getPointsPath()).thenReturn("Points");
+ when(cache.getCache()).thenReturn(values);
+ doCallRealMethod().when(user).clearCache();
+ doAnswer(invocation -> {
+ assertFalse(values.containsKey("Points"),
+ "the prediction must be removed before clearCache can dump it");
+ return null;
+ }).when(cache).clearCache();
+ SharedMysqlCacheReconciler.recordOptimisticPoint(cache, "Points", prediction);
+
+ user.clearCache();
+
+ verify(cache).clearCache();
+ }
+
+ @Test
+ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ java.sql.ResultSet result = mock(java.sql.ResultSet.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement, read);
+ when(result.next()).thenReturn(true);
+ when(result.getInt(1)).thenReturn(73);
+ when(read.executeQuery()).thenReturn(result);
+ when(statement.executeUpdate()).thenReturn(1);
+
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL);
+ when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false);
+ when(plugin.getMysql()).thenReturn(table);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ UserData data = mock(UserData.class);
+ when(user.getUserData()).thenReturn(data);
+ when(data.getInt("Points", UserDataFetchMode.NO_CACHE)).thenReturn(10);
+
+ assertEquals(73, new SharedMysqlPointMutator(plugin).add(user, 10, false));
+
+ ArgumentCaptor query = ArgumentCaptor.forClass(String.class);
+ verify(connection, times(2)).prepareStatement(query.capture());
+ assertTrue(query.getAllValues().get(0).contains("`Points` = `Points` + ?"));
+ assertTrue(query.getAllValues().get(1).contains("SELECT `Points`"));
+ verify(statement).setInt(1, 10);
+ verify(statement).executeUpdate();
+ verify(read).executeQuery();
+ verify(data, never()).getInt("Points", UserDataFetchMode.NO_CACHE);
+ verify(persistence, never()).execute(any(Runnable.class));
+ }
+
+ @Test
+ void committedAddIsNotReportedRetryableWhenFollowUpReadFails() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(update, read);
+ when(update.executeUpdate()).thenReturn(1);
+ when(read.executeQuery()).thenThrow(new java.sql.SQLException("connection lost after update"));
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ when(user.getPoints()).thenReturn(10);
+
+ SharedMysqlPointMutator.AddResult result = new SharedMysqlPointMutator(plugin).addCommitted(user, 5);
+
+ assertTrue(result.success(), "a committed update must not invite a duplicate retry");
+ assertEquals(10, result.total(), "the stale total is safer than reporting a retryable failure");
+ verify(update).executeUpdate();
+ verify(read).executeQuery();
+ org.mockito.InOrder closeBeforeFallback = inOrder(connection, user);
+ closeBeforeFallback.verify(connection).close();
+ closeBeforeFallback.verify(user).getPoints();
+ }
+
+ @Test
+ void committedAddDefersEmptyReadFallbackUntilConnectionCloses() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ java.sql.ResultSet empty = mock(java.sql.ResultSet.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(update, read);
+ when(update.executeUpdate()).thenReturn(1);
+ when(read.executeQuery()).thenReturn(empty);
+ when(empty.next()).thenReturn(false);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ when(user.getPoints()).thenReturn(17);
+
+ SharedMysqlPointMutator.AddResult result = new SharedMysqlPointMutator(plugin).addCommitted(user, 5);
+
+ assertTrue(result.success());
+ assertEquals(17, result.total());
+ org.mockito.InOrder closeBeforeFallback = inOrder(connection, user);
+ closeBeforeFallback.verify(connection).close();
+ closeBeforeFallback.verify(user).getPoints();
+ }
+
+ @Test
+ void capUsesLeastSoItCannotRestoreAConcurrentDebit() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+
+ new SharedMysqlPointMutator(plugin).cap(user, 100, false);
+
+ ArgumentCaptor query = ArgumentCaptor.forClass(String.class);
+ verify(connection).prepareStatement(query.capture());
+ assertTrue(query.getValue().contains("`Points` = LEAST(`Points`, ?)"));
+ }
+
+ @Test
+ void addAndCapUsesOneAtomicPersistenceMutation() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement statement = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(statement);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(95));
+ when(user.isCached()).thenReturn(true);
+ when(user.getCache()).thenReturn(cache);
+ when(cache.getCache()).thenReturn(values);
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(
+ new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache)));
+ org.mockito.Mockito.doAnswer(invocation -> {
+ assertFalse(values.containsKey("Points"), "the capped prediction must not be dumped before SQL caps it");
+ return null;
+ }).when(cache).dump();
+
+ new SharedMysqlPointMutator(plugin).addAndCap(user, 10, 100, true);
+ assertEquals(100, values.get("Points").getInt());
+
+ ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class);
+ verify(persistence).execute(task.capture());
+ verify(persistence, times(1)).execute(any(Runnable.class));
+ verify(sql.getConnectionManager(), never()).getConnection();
+ task.getValue().run();
+
+ ArgumentCaptor query = ArgumentCaptor.forClass(String.class);
+ verify(connection).prepareStatement(query.capture());
+ assertTrue(query.getValue().contains("`Points` = LEAST(`Points` + ?, ?)"));
+ verify(statement).setInt(1, 10);
+ verify(statement).setInt(2, 100);
+ verify(statement).setString(3, "00000000-0000-0000-0000-000000000001");
+ verify(statement).executeUpdate();
+ assertFalse(values.containsKey("Points"));
+ }
+
+ @Test
+ void rejectedAddAndCapSubmissionDiscardsItsPrediction() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ ScheduledExecutorService persistence = mock(ScheduledExecutorService.class);
+ when(plugin.getTimer()).thenReturn(persistence);
+ doThrow(new RejectedExecutionException("saturated")).when(persistence).execute(any(Runnable.class));
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(user.getPointsPath()).thenReturn("Points");
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("Points", new DataValueInt(95));
+ when(user.isCached()).thenReturn(true);
+ when(user.getCache()).thenReturn(cache);
+ when(cache.getCache()).thenReturn(values);
+ UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001");
+ when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(
+ new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, cache)));
+
+ new SharedMysqlPointMutator(plugin).addAndCap(user, 10, 100, true);
+
+ assertFalse(values.containsKey("Points"));
+ verify(plugin.getMysql(), never()).getMysql();
+ }
+
+ @Test
+ void transferCreditsOnlyAfterConditionalDebitSucceeds() throws Exception {
+ MySQL table = mock(MySQL.class);
+ com.bencodez.simpleapi.sql.mysql.MySQL sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ Connection connection = mock(Connection.class);
+ PreparedStatement debit = mock(PreparedStatement.class);
+ PreparedStatement credit = mock(PreparedStatement.class);
+ when(table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(table.getMysql()).thenReturn(sql);
+ when(sql.getConnectionManager().getConnection()).thenReturn(connection);
+ when(connection.prepareStatement(anyString())).thenReturn(debit, credit);
+ when(debit.executeUpdate()).thenReturn(1);
+ when(credit.executeUpdate()).thenReturn(1);
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getMysql()).thenReturn(table);
+ VotingPluginUser source = mock(VotingPluginUser.class);
+ VotingPluginUser target = mock(VotingPluginUser.class);
+ when(source.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002");
+ when(source.getPointsPath()).thenReturn("Points");
+ when(target.getPointsPath()).thenReturn("Points");
+
+ assertTrue(new SharedMysqlPointMutator(plugin).transfer(source, target, 10));
+
+ verify(debit).executeUpdate();
+ verify(credit).executeUpdate();
+ verify(connection).commit();
+ verify(connection, times(0)).rollback();
+ }
+
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java
new file mode 100644
index 0000000000..260619de17
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java
@@ -0,0 +1,599 @@
+package com.bencodez.votingplugin.user;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.atLeastOnce;
+import static org.mockito.Mockito.doThrow;
+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.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+
+class SharedPointAdditionJournalTest {
+ @Test
+ void conditionalDebitIsJournaledAndCanBeRetriedWithoutASecondDebit() throws Exception {
+ Fixture fixture = fixture();
+ Connection missing = missingLookup();
+ Attempt debit = successfulAttempt(7);
+ Connection completed = completedLookup("player", "Points", -3, 7);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, debit.connection(), completed);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertEquals(7, journal.subtract("admin-remove", "player", "Points", 3, 100L).total());
+ assertEquals(7, journal.subtract("admin-remove", "player", "Points", 3, 101L).total());
+
+ verify(debit.credit(), times(1)).executeUpdate();
+ verify(debit.credit()).setInt(1, -3);
+ verify(debit.credit()).setInt(3, 3);
+ }
+
+ @Test
+ void conditionalDebitRejectsMissingOrInsufficientUserWithoutCompletingTheJournal() throws Exception {
+ Fixture fixture = fixture();
+ Connection missing = missingLookup();
+ Connection attempt = mock(Connection.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement debit = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ when(debit.executeUpdate()).thenReturn(0);
+ when(attempt.prepareStatement(anyString())).thenReturn(insert, debit, read, complete);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, attempt);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertThrows(SharedPointAdditionJournal.DebitRejectedException.class,
+ () -> journal.subtract("admin-remove", "player", "Points", 11, 100L));
+ verify(attempt, atLeastOnce()).rollback();
+ verify(complete, org.mockito.Mockito.never()).executeUpdate();
+ }
+
+ @Test
+ void lostCommitAcknowledgementAndFailedConfirmationRetryCreditsExactlyOnce() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement missingLookup = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(missing.next()).thenReturn(false);
+ when(missingLookup.executeQuery()).thenReturn(missing);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(missingLookup);
+
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement credit = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ ResultSet total = mock(ResultSet.class);
+ when(credit.executeUpdate()).thenReturn(1);
+ when(total.next()).thenReturn(true);
+ when(total.getInt(1)).thenReturn(15);
+ when(read.executeQuery()).thenReturn(total);
+ when(complete.executeUpdate()).thenReturn(1);
+ when(fixture.firstAttempt.prepareStatement(anyString())).thenReturn(insert, credit, read, complete);
+ doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(fixture.firstAttempt).commit();
+ when(fixture.failedConfirmation.prepareStatement(anyString()))
+ .thenThrow(new java.sql.SQLException("confirmation unavailable"));
+
+ PreparedStatement retryLookup = mock(PreparedStatement.class);
+ ResultSet completed = completedRow("player", "Points", 5, 15);
+ when(retryLookup.executeQuery()).thenReturn(completed);
+ when(fixture.retryLookup.prepareStatement(anyString())).thenReturn(retryLookup);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.initialLookup, fixture.firstAttempt,
+ fixture.failedConfirmation, fixture.retryLookup);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertThrows(java.sql.SQLException.class, () -> journal.add("reward-operation", "player", "Points", 5, 100L));
+
+ SharedPointAdditionJournal.AdditionResult result = journal.add("reward-operation", "player", "Points", 5,
+ 101L);
+ assertEquals(15, result.total());
+ verify(credit, times(1)).executeUpdate();
+ verify(fixture.firstAttempt, atLeastOnce()).close();
+ verify(retryLookup).setString(1, "reward-operation");
+ }
+
+ @Test
+ void completedOperationRejectsAConflictingRetryInsteadOfChangingPoints() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet completed = completedRow("player", "Points", 5, 15);
+ when(lookup.executeQuery()).thenReturn(completed);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertThrows(java.sql.SQLException.class, () -> journal.add("reward-operation", "player", "Points", 6, 100L));
+ verify(fixture.firstAttempt, org.mockito.Mockito.never()).prepareStatement(anyString());
+ }
+
+ @Test
+ void completedOperationCanBeFoundBeforeReplayingTheReceiveHook() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet completed = completedRow("player", "Points", 7, 17);
+ when(lookup.executeQuery()).thenReturn(completed);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup);
+
+ SharedPointAdditionJournal.AdditionResult result = new SharedPointAdditionJournal(fixture.table, false)
+ .findCompleted("reward-operation", "player", "Points");
+
+ assertEquals(17, result.total());
+ verify(lookup).setString(1, "reward-operation");
+ verify(fixture.firstAttempt, org.mockito.Mockito.never()).prepareStatement(anyString());
+ }
+
+ @Test
+ void claimedHookPreventsAnotherBackendFromReplayingTheReceiveEvent() throws Exception {
+ Fixture fixture = fixture();
+ Connection missing = missingLookup();
+ Connection claim = mock(Connection.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ when(claim.prepareStatement(anyString())).thenReturn(insert);
+ Connection otherBackend = mock(Connection.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend");
+ when(lookup.executeQuery()).thenReturn(claimed);
+ when(otherBackend.prepareStatement(anyString())).thenReturn(lookup);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, claim, otherBackend);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertTrue(journal.claimHook("reward-operation", "player", "Points", 5, "first-backend", 100L).claimed());
+ SharedPointAdditionJournal.HookClaim duplicate = journal.claimHook("reward-operation", "player", "Points", 5,
+ "second-backend", 101L);
+
+ assertFalse(duplicate.claimed());
+ assertFalse(duplicate.completed());
+ assertTrue(duplicate.requiresReconciliation());
+ verify(insert, times(1)).executeUpdate();
+ }
+
+ @Test
+ void rejectedOrStoppedHookClaimIsDurablyMarkedForManualReconciliation() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend");
+ when(select.executeQuery()).thenReturn(claimed);
+ when(update.executeUpdate()).thenReturn(1);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, update);
+
+ new SharedPointAdditionJournal(fixture.table, false).markIndeterminate("reward-operation", "player",
+ "Points", 5, "first-backend");
+
+ verify(update).setString(1, "INDETERMINATE");
+ verify(update).setString(2, "reward-operation");
+ verify(update).setString(3, "HOOK_STARTED");
+ verify(update).setString(4, "first-backend");
+ verify(fixture.initialLookup).commit();
+ }
+
+ @Test
+ void provenUnstartedHookClaimIsReleasedForASafeRetry() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement delete = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend");
+ when(select.executeQuery()).thenReturn(claimed);
+ when(delete.executeUpdate()).thenReturn(1);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, delete);
+
+ new SharedPointAdditionJournal(fixture.table, false).releaseUnstartedHook("reward-operation", "player",
+ "Points", 5, "first-backend");
+
+ verify(delete).setString(1, "reward-operation");
+ verify(delete).setString(2, "HOOK_STARTED");
+ verify(delete).setString(3, "first-backend");
+ verify(delete).executeUpdate();
+ verify(fixture.initialLookup).commit();
+ }
+
+ @Test
+ void ambiguousHookClaimReleasesTheExactUnstartedOwnerBeforeFailing() throws Exception {
+ Fixture fixture = fixture();
+ Connection missing = missingLookup();
+ Connection claim = mock(Connection.class);
+ Connection confirmation = mock(Connection.class);
+ Connection release = mock(Connection.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement delete = mock(PreparedStatement.class);
+ when(claim.prepareStatement(anyString())).thenReturn(insert);
+ doThrow(new java.sql.SQLException("claim acknowledgement lost")).when(claim).commit();
+ when(confirmation.prepareStatement(anyString()))
+ .thenThrow(new java.sql.SQLException("confirmation unavailable"));
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend");
+ when(select.executeQuery()).thenReturn(claimed);
+ when(delete.executeUpdate()).thenReturn(1);
+ when(release.prepareStatement(anyString())).thenReturn(select, delete);
+ when(fixture.sql.getConnectionManager().getConnection())
+ .thenReturn(missing, claim, confirmation, release);
+
+ assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false)
+ .claimHook("reward-operation", "player", "Points", 5, "first-backend", 100L));
+
+ verify(delete).setString(1, "reward-operation");
+ verify(delete).setString(2, "HOOK_STARTED");
+ verify(delete).setString(3, "first-backend");
+ verify(delete).executeUpdate();
+ verify(release).commit();
+ }
+
+ @Test
+ void ambiguousUnstartedHookReleaseCommitIsConfirmedAsSafeAfterRestart() throws Exception {
+ Fixture fixture = fixture();
+ Connection release = mock(Connection.class);
+ Connection confirmation = mock(Connection.class);
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement delete = mock(PreparedStatement.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend");
+ ResultSet missing = mock(ResultSet.class);
+ when(claimed.next()).thenReturn(true);
+ when(select.executeQuery()).thenReturn(claimed);
+ when(delete.executeUpdate()).thenReturn(1);
+ when(release.prepareStatement(anyString())).thenReturn(select, delete);
+ doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(release).commit();
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ when(confirmation.prepareStatement(anyString())).thenReturn(lookup);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(release, confirmation);
+
+ new SharedPointAdditionJournal(fixture.table, false).releaseUnstartedHook("reward-operation", "player",
+ "Points", 5, "first-backend");
+
+ verify(release, atLeastOnce()).close();
+ verify(lookup).setString(1, "reward-operation");
+ }
+
+ @Test
+ void restartedBackendReportsIndeterminateClaimInsteadOfWaitingForADeadOwner() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet indeterminate = hookStartedRow("player", "Points", 5, "stopped-backend");
+ when(indeterminate.getString(4)).thenReturn("INDETERMINATE");
+ when(lookup.executeQuery()).thenReturn(indeterminate);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup);
+
+ SharedPointAdditionJournal.HookClaim claim = new SharedPointAdditionJournal(fixture.table, false)
+ .claimHook("reward-operation", "player", "Points", 5, "restarted-backend", 101L);
+
+ assertFalse(claim.claimed());
+ assertTrue(claim.requiresReconciliation());
+ }
+
+ @Test
+ void liveForeignHookClaimIsNotPreemptedBeforeItsRecoveryLeaseExpires() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet live = hookStartedRow("player", "Points", 5, "live-backend", 100L);
+ when(lookup.executeQuery()).thenReturn(live);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup);
+
+ SharedPointAdditionJournal.HookClaim claim = new SharedPointAdditionJournal(fixture.table, false)
+ .claimHook("reward-operation", "player", "Points", 5, "replacement",
+ 100L + SharedPointAdditionJournal.HOOK_RECOVERY_LEASE_MILLIS - 1L);
+
+ assertFalse(claim.claimed());
+ assertTrue(claim.requiresReconciliation());
+ verify(fixture.sql.getConnectionManager(), times(1)).getConnection();
+ }
+
+ @Test
+ void staleForeignHookClaimTransitionsDurablyToReconciliation() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet stale = hookStartedRow("player", "Points", 5, "stopped-backend", 100L);
+ when(lookup.executeQuery()).thenReturn(stale);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup);
+ Connection transition = mock(Connection.class);
+ PreparedStatement update = mock(PreparedStatement.class);
+ when(update.executeUpdate()).thenReturn(1);
+ when(transition.prepareStatement(anyString())).thenReturn(update);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.initialLookup, transition);
+ long now = 100L + SharedPointAdditionJournal.HOOK_RECOVERY_LEASE_MILLIS;
+
+ SharedPointAdditionJournal.HookClaim claim = new SharedPointAdditionJournal(fixture.table, false)
+ .claimHook("reward-operation", "player", "Points", 5, "replacement", now);
+
+ assertFalse(claim.claimed());
+ assertTrue(claim.requiresReconciliation());
+ verify(update).setString(1, "INDETERMINATE");
+ verify(update).setString(2, "reward-operation");
+ verify(update).setString(3, "HOOK_STARTED");
+ verify(update).setLong(4, 100L);
+ verify(transition).commit();
+ }
+
+ @Test
+ void cancelledHookSettlesWithARepresentableZeroCredit() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "owner");
+ ResultSet total = mock(ResultSet.class);
+ when(total.next()).thenReturn(true);
+ when(total.getInt(1)).thenReturn(12);
+ when(select.executeQuery()).thenReturn(claimed);
+ when(read.executeQuery()).thenReturn(total);
+ when(complete.executeUpdate()).thenReturn(1);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, read, complete);
+
+ assertEquals(12, new SharedPointAdditionJournal(fixture.table, false).settleClaim("reward-operation",
+ "player", "Points", "Points", 5, "owner", null).total());
+
+ verify(complete).setInt(1, 0);
+ verify(complete, org.mockito.Mockito.never()).setNull(org.mockito.ArgumentMatchers.eq(1),
+ org.mockito.ArgumentMatchers.anyInt());
+ }
+
+ @Test
+ void perServerSettlementCreditsTheLocalColumnAndCompletesTheSharedClaimAtomically() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement credit = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "owner");
+ ResultSet total = mock(ResultSet.class);
+ when(credit.executeUpdate()).thenReturn(1);
+ when(total.next()).thenReturn(true);
+ when(total.getInt(1)).thenReturn(17);
+ when(select.executeQuery()).thenReturn(claimed);
+ when(read.executeQuery()).thenReturn(total);
+ when(complete.executeUpdate()).thenReturn(1);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, credit, read, complete);
+
+ assertEquals(17, new SharedPointAdditionJournal(fixture.table, false).settleClaim("reward-operation",
+ "player", "Points", "lobby_Points", 5, "owner", Integer.valueOf(3)).total());
+
+ verify(credit).setInt(1, 3);
+ verify(credit).setString(2, "player");
+ verify(complete).setInt(1, 3);
+ verify(complete).setString(2, "COMPLETED");
+ verify(complete).setInt(3, 17);
+ org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.initialLookup, times(4)).prepareStatement(statements.capture());
+ assertTrue(statements.getAllValues().stream().anyMatch(statement -> statement.startsWith(
+ "UPDATE `VotingPlugin_Users` SET `lobby_Points` = COALESCE(`lobby_Points`, 0) + ?")));
+ assertTrue(statements.getAllValues().stream().anyMatch(statement -> statement.startsWith(
+ "SELECT `lobby_Points` FROM `VotingPlugin_Users`")));
+ assertTrue(statements.getAllValues().stream().noneMatch(statement -> statement.startsWith(
+ "UPDATE `VotingPlugin_Users` SET `Points` = `Points` + ?")));
+ verify(fixture.initialLookup).commit();
+ }
+
+ @Test
+ void failedPerServerLocalCreditRollsBackBeforeTheJournalCanComplete() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement credit = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "owner");
+ when(select.executeQuery()).thenReturn(claimed);
+ when(credit.executeUpdate()).thenThrow(new java.sql.SQLException("local write failed"));
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, credit, read, complete);
+
+ assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false)
+ .settleClaim("reward-operation", "player", "Points", "lobby_Points", 5, "owner",
+ Integer.valueOf(3)));
+
+ verify(fixture.initialLookup, atLeastOnce()).rollback();
+ verify(complete, org.mockito.Mockito.never()).executeUpdate();
+ }
+
+ @Test
+ void settlementRejectsAnUnsafePhysicalCreditColumnBeforeOpeningSql() throws Exception {
+ Fixture fixture = fixture();
+
+ assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false)
+ .settleClaim("reward-operation", "player", "Points", "lobby\0Points", 5, "owner",
+ Integer.valueOf(3)));
+ verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.never()).getConnection();
+ }
+
+ @Test
+ void claimedHookRejectsAConflictingRequestedAmountBeforeAnotherEventCanRun() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet claimed = hookStartedRow("player", "Points", 5, "first-backend");
+ when(lookup.executeQuery()).thenReturn(claimed);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(lookup);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertThrows(java.sql.SQLException.class,
+ () -> journal.claimHook("reward-operation", "player", "Points", 6, "second-backend", 101L));
+ verify(fixture.firstAttempt, org.mockito.Mockito.never()).prepareStatement(anyString());
+ }
+
+ @Test
+ void distinctRewardOccurrencesCreditIndependentlyWhileRetryingOneDoesNot() throws Exception {
+ Fixture fixture = fixture();
+ Connection firstLookup = missingLookup();
+ Attempt firstAttempt = successfulAttempt(15);
+ Connection secondLookup = missingLookup();
+ Attempt secondAttempt = successfulAttempt(20);
+ Connection retryLookup = completedLookup("player", "Points", 5, 15);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(firstLookup, firstAttempt.connection(), secondLookup,
+ secondAttempt.connection(), retryLookup);
+
+ SharedPointAdditionJournal journal = new SharedPointAdditionJournal(fixture.table, false);
+ assertEquals(15, journal.add("occurrence-one/stage", "player", "Points", 5, 100L).total());
+ assertEquals(20, journal.add("occurrence-two/stage", "player", "Points", 5, 101L).total());
+ assertEquals(15, journal.add("occurrence-one/stage", "player", "Points", 5, 102L).total());
+
+ verify(firstAttempt.credit(), times(1)).executeUpdate();
+ verify(secondAttempt.credit(), times(1)).executeUpdate();
+ }
+
+ @Test
+ void journalTableNameRemainsPortableForLongSourceNames() {
+ String name = SharedPointAdditionJournal.journalTableName("u".repeat(80));
+ assertTrue(name.matches("vp_pa_[0-9a-f]{32}"));
+ assertEquals("VotingPlugin_Users_PointAdditions",
+ SharedPointAdditionJournal.journalTableName("VotingPlugin_Users"));
+ }
+
+ @Test
+ void onlyAcknowledgedOrEphemeralAdminEntriesExpireInABoundedRetentionBatch() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ PreparedStatement delete = mock(PreparedStatement.class);
+ ResultSet completed = mock(ResultSet.class);
+ when(completed.next()).thenReturn(true, true, false);
+ when(completed.getString(1)).thenReturn("old-one", "old-two");
+ when(select.executeQuery()).thenReturn(completed);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, delete);
+
+ long now = TimeUnit.DAYS.toMillis(10);
+ new SharedPointAdditionJournal(fixture.table, false).cleanupAcknowledged(now);
+
+ verify(select).setString(1, "ACKNOWLEDGED");
+ verify(select).setString(2, "COMPLETED");
+ verify(select).setString(3, "admin-points/%");
+ verify(select).setString(4, "admin-bulk-points/%");
+ verify(select).setString(5, "admin-bulk-remove/%");
+ verify(select).setString(6, "remove-points/%");
+ verify(select).setLong(7, now - SharedPointAdditionJournal.COMPLETED_RETENTION_MILLIS);
+ verify(select).setInt(8, 100);
+ verify(delete, times(2)).setString(3, "ACKNOWLEDGED");
+ verify(delete, times(2)).setString(4, "COMPLETED");
+ verify(delete, times(2)).executeUpdate();
+ }
+
+ @Test
+ void durableReplayCheckpointAcknowledgesAnAdditionBeforeRetentionStarts() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement update = mock(PreparedStatement.class);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(update);
+
+ new SharedPointAdditionJournal(fixture.table, false).acknowledge("reward-operation", 123L);
+
+ verify(update).setString(1, "ACKNOWLEDGED");
+ verify(update).setLong(2, 123L);
+ verify(update).setString(3, "reward-operation");
+ verify(update).setString(4, "COMPLETED");
+ verify(update).executeUpdate();
+ }
+
+ @Test
+ void schemaIndexesTheBoundedCleanupPredicate() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement createTable = mock(PreparedStatement.class);
+ PreparedStatement addRequestedAmount = mock(PreparedStatement.class);
+ PreparedStatement addHookOwner = mock(PreparedStatement.class);
+ PreparedStatement createIndex = mock(PreparedStatement.class);
+ when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(createTable, addRequestedAmount,
+ addHookOwner, createIndex);
+
+ new SharedPointAdditionJournal(fixture.table, true);
+
+ org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class);
+ verify(fixture.initialLookup, times(4)).prepareStatement(statements.capture());
+ assertTrue(statements.getAllValues().get(3).contains("(`state`, `created_at`)"));
+ }
+
+ private static ResultSet completedRow(String uuid, String pointsColumn, int amount, int total) throws Exception {
+ ResultSet row = mock(ResultSet.class);
+ when(row.next()).thenReturn(true);
+ when(row.getString(1)).thenReturn(uuid);
+ when(row.getString(2)).thenReturn(pointsColumn);
+ when(row.getInt(3)).thenReturn(amount);
+ when(row.getString(4)).thenReturn("COMPLETED");
+ when(row.getObject(5)).thenReturn(Integer.valueOf(total));
+ when(row.getInt(5)).thenReturn(total);
+ return row;
+ }
+
+ private static ResultSet hookStartedRow(String uuid, String pointsColumn, int requestedAmount, String owner)
+ throws Exception {
+ return hookStartedRow(uuid, pointsColumn, requestedAmount, owner, 0L);
+ }
+
+ private static ResultSet hookStartedRow(String uuid, String pointsColumn, int requestedAmount, String owner,
+ long createdAt) throws Exception {
+ ResultSet row = mock(ResultSet.class);
+ when(row.next()).thenReturn(true);
+ when(row.getString(1)).thenReturn(uuid);
+ when(row.getString(2)).thenReturn(pointsColumn);
+ when(row.getInt(3)).thenReturn(requestedAmount);
+ when(row.getString(4)).thenReturn("HOOK_STARTED");
+ when(row.getObject(5)).thenReturn(null);
+ when(row.getObject(6)).thenReturn(Integer.valueOf(requestedAmount));
+ when(row.getInt(6)).thenReturn(requestedAmount);
+ when(row.getString(7)).thenReturn(owner);
+ when(row.getLong(8)).thenReturn(createdAt);
+ return row;
+ }
+
+ private static Connection missingLookup() throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ when(connection.prepareStatement(anyString())).thenReturn(lookup);
+ return connection;
+ }
+
+ private static Connection completedLookup(String uuid, String pointsColumn, int amount, int total) throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet completed = completedRow(uuid, pointsColumn, amount, total);
+ when(lookup.executeQuery()).thenReturn(completed);
+ when(connection.prepareStatement(anyString())).thenReturn(lookup);
+ return connection;
+ }
+
+ private static Attempt successfulAttempt(int total) throws Exception {
+ Connection connection = mock(Connection.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement credit = mock(PreparedStatement.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ PreparedStatement complete = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ when(credit.executeUpdate()).thenReturn(1);
+ when(result.next()).thenReturn(true);
+ when(result.getInt(1)).thenReturn(total);
+ when(read.executeQuery()).thenReturn(result);
+ when(complete.executeUpdate()).thenReturn(1);
+ when(connection.prepareStatement(anyString())).thenReturn(insert, credit, read, complete);
+ return new Attempt(connection, credit);
+ }
+
+ private record Attempt(Connection connection, PreparedStatement credit) {}
+
+ private static Fixture fixture() throws Exception {
+ Fixture fixture = new Fixture();
+ fixture.table = mock(MySQL.class);
+ fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ fixture.initialLookup = mock(Connection.class);
+ fixture.firstAttempt = mock(Connection.class);
+ fixture.failedConfirmation = mock(Connection.class);
+ fixture.retryLookup = mock(Connection.class);
+ when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(fixture.table.getMysql()).thenReturn(fixture.sql);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.initialLookup, fixture.firstAttempt,
+ fixture.failedConfirmation, fixture.retryLookup);
+ return fixture;
+ }
+
+ private static final class Fixture {
+ MySQL table;
+ com.bencodez.simpleapi.sql.mysql.MySQL sql;
+ Connection initialLookup;
+ Connection firstAttempt;
+ Connection failedConfirmation;
+ Connection retryLookup;
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java
new file mode 100644
index 0000000000..a9ecefd553
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java
@@ -0,0 +1,491 @@
+package com.bencodez.votingplugin.user;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.doThrow;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+
+import org.junit.jupiter.api.Test;
+
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+
+class SharedPointTransferJournalTest {
+ @Test
+ void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() {
+ String source = "u".repeat(80);
+ String journalTable = SharedPointTransferJournal.journalTableName(source);
+
+ assertEquals(journalTable, SharedPointTransferJournal.journalTableName(source));
+ assertTrue(journalTable.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 63);
+ assertTrue(journalTable.matches("vp_pt_[0-9a-f]{32}"));
+ assertNotEquals(journalTable, SharedPointTransferJournal.journalTableName(source + "x"));
+ assertTrue(SharedPointTransferJournal.journalTableName("é".repeat(30)).matches("vp_pt_[0-9a-f]{32}"));
+ assertEquals("VotingPlugin_Users_PointTransfers",
+ SharedPointTransferJournal.journalTableName("VotingPlugin_Users"));
+ }
+
+ @Test
+ void schemaInitializationIsOncePerLiveMysqlHandle() throws Exception {
+ Fixture fixture = fixture();
+ assertNotNull(SharedPointTransferJournal.forTable(fixture.table));
+ assertNotNull(SharedPointTransferJournal.forTable(fixture.table));
+ verify(fixture.sql.getConnectionManager(), times(1)).getConnection();
+ }
+
+ @Test
+ void reservationDebitsAndJournalsInOneShortTransaction() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement debit = mock(PreparedStatement.class);
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ when(debit.executeUpdate()).thenReturn(1);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup);
+ when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.reserve("transfer-1", "source", "Points", 10, "target", 10, 100L));
+
+ verify(insert).setString(7, "RESERVED");
+ verify(debit).setInt(1, 10);
+ verify(fixture.reservation).commit();
+ // commitAndConfirm closes before its confirmation lookup; the enclosing
+ // try-with-resources then closes the same JDBC handle idempotently.
+ verify(fixture.reservation, atLeastOnce()).close();
+ }
+
+ @Test
+ void insufficientSourceRollsBackTheJournalInsertAndDoesNotRunAHook() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement debit = mock(PreparedStatement.class);
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ when(debit.executeUpdate()).thenReturn(0);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup);
+ when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertFalse(journal.reserve("transfer-2", "source", "Points", 10, "target", 10, 100L));
+
+ verify(fixture.reservation).rollback();
+ }
+
+ @Test
+ void cancelledHookRefundsExactlyTheReservedDebit() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ ResultSet row = row("HOOK_STARTED", "owner-1");
+ PreparedStatement refund = mock(PreparedStatement.class);
+ PreparedStatement journalUpdate = mock(PreparedStatement.class);
+ when(select.executeQuery()).thenReturn(row);
+ when(refund.executeUpdate()).thenReturn(1);
+ when(journalUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(select, refund, journalUpdate);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertFalse(journal.settle("transfer-3", "owner-1", "source", "Points", "target", "Points", 10, null));
+
+ verify(refund).setInt(1, 10);
+ verify(refund).setString(2, "source");
+ verify(journalUpdate).setString(1, "REFUNDED");
+ verify(fixture.lookup).commit();
+ }
+
+ @Test
+ void rejectedApprovalTaskCanRefundAClaimedTransfer() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ ResultSet row = row("HOOK_STARTED", "owner-1");
+ PreparedStatement refund = mock(PreparedStatement.class);
+ PreparedStatement journalUpdate = mock(PreparedStatement.class);
+ when(select.executeQuery()).thenReturn(row);
+ when(refund.executeUpdate()).thenReturn(1);
+ when(journalUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(select, refund, journalUpdate);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.refundHookStarted("transfer-rejected", "source", "Points", 10));
+
+ verify(fixture.lookup).setAutoCommit(false);
+ verify(refund).setInt(1, 10);
+ verify(journalUpdate).setString(1, "REFUNDED");
+ verify(fixture.lookup).commit();
+ }
+
+ @Test
+ void compensationMarkerIsDurableAfterTheSchedulerFenceRejectsTheHook() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement update = mock(PreparedStatement.class);
+ when(update.executeUpdate()).thenReturn(1);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(update);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.markCompensating("transfer-compensating"));
+
+ verify(update).setString(1, "COMPENSATING");
+ verify(update).setString(2, "transfer-compensating");
+ verify(update).setString(3, "HOOK_STARTED");
+ verify(update).setString(4, "COMPENSATING");
+ verify(fixture.lookup).commit();
+ }
+
+ @Test
+ void acceptedHookCreditsAdjustedAmountAndMarksTerminalState() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement select = mock(PreparedStatement.class);
+ ResultSet row = row("HOOK_STARTED", "owner-2");
+ PreparedStatement credit = mock(PreparedStatement.class);
+ PreparedStatement journalUpdate = mock(PreparedStatement.class);
+ when(select.executeQuery()).thenReturn(row);
+ when(credit.executeUpdate()).thenReturn(1);
+ when(journalUpdate.executeUpdate()).thenReturn(1);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(select, credit, journalUpdate);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.settle("transfer-4", "owner-2", "source", "Points", "target", "Points", 10, 4));
+
+ verify(credit).setInt(1, 4);
+ verify(credit).setString(2, "target");
+ verify(journalUpdate).setString(1, "COMPLETED");
+ verify(journalUpdate).setInt(2, 4);
+ verify(fixture.lookup).commit();
+ }
+
+ @Test
+ void ambiguousReservationCommitIsConfirmedAfterConnectionIsReleased() throws Exception {
+ Fixture fixture = fixture();
+ PreparedStatement initialLookup = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ PreparedStatement insert = mock(PreparedStatement.class);
+ PreparedStatement debit = mock(PreparedStatement.class);
+ PreparedStatement confirmationLookup = mock(PreparedStatement.class);
+ ResultSet confirmed = transferRow("source", "target", 10, 10, "RESERVED");
+ when(missing.next()).thenReturn(false);
+ when(initialLookup.executeQuery()).thenReturn(missing);
+ when(debit.executeUpdate()).thenReturn(1);
+ when(fixture.lookup.prepareStatement(anyString())).thenReturn(initialLookup);
+ when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit);
+ Connection confirmation = mock(Connection.class);
+ when(confirmation.prepareStatement(anyString())).thenReturn(confirmationLookup);
+ when(confirmationLookup.executeQuery()).thenReturn(confirmed);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, fixture.lookup,
+ fixture.reservation, confirmation);
+ doThrow(new java.sql.SQLException("ack lost")).when(fixture.reservation).commit();
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.reserve("transfer-5", "source", "Points", 10, "target", 10, 100L));
+
+ // Confirmation must happen after the possibly-broken handle is released;
+ // the outer try-with-resources may then close it idempotently once more.
+ verify(fixture.reservation, atLeastOnce()).close();
+ verify(confirmationLookup).executeQuery();
+ }
+
+ @Test
+ void ambiguousClaimCommitIsConfirmedBeforeTheHookMayRun() throws Exception {
+ Fixture fixture = fixture();
+ Connection claim = mock(Connection.class);
+ Connection confirmation = mock(Connection.class);
+ PreparedStatement claimSelect = mock(PreparedStatement.class);
+ PreparedStatement claimUpdate = mock(PreparedStatement.class);
+ PreparedStatement confirmationLookup = mock(PreparedStatement.class);
+ ResultSet reserved = row("RESERVED", null);
+ ResultSet confirmed = transferRow("source", "target", 10, 10, "HOOK_STARTED");
+ when(claim.prepareStatement(anyString())).thenReturn(claimSelect, claimUpdate);
+ when(claimSelect.executeQuery()).thenReturn(reserved);
+ when(claimUpdate.executeUpdate()).thenReturn(1);
+ doThrow(new java.sql.SQLException("ack lost")).when(claim).commit();
+ when(confirmation.prepareStatement(anyString())).thenReturn(confirmationLookup);
+ when(confirmationLookup.executeQuery()).thenReturn(confirmed);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, claim, confirmation);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.claimHookWithConfirmation("transfer-claim", "owner-claim", 100L)
+ == SharedPointTransferJournal.ClaimOutcome.CLAIMED);
+
+ verify(claim, atLeastOnce()).close();
+ verify(confirmationLookup).executeQuery();
+ }
+
+ @Test
+ void ambiguousClaimThatCannotBeConfirmedRemainsIndeterminate() throws Exception {
+ Fixture fixture = fixture();
+ Connection claim = mock(Connection.class);
+ Connection unavailable = mock(Connection.class);
+ PreparedStatement claimSelect = mock(PreparedStatement.class);
+ PreparedStatement claimUpdate = mock(PreparedStatement.class);
+ ResultSet reserved = row("RESERVED", null);
+ when(claim.prepareStatement(anyString())).thenReturn(claimSelect, claimUpdate);
+ when(claimSelect.executeQuery()).thenReturn(reserved);
+ when(claimUpdate.executeUpdate()).thenReturn(1);
+ doThrow(new java.sql.SQLException("ack lost")).when(claim).commit();
+ when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("database unavailable"));
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, claim, unavailable,
+ unavailable, unavailable);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.claimHookWithConfirmation("transfer-unknown", "owner", 100L)
+ == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE);
+ }
+
+ @Test
+ void settlementRetriesTheSameTransferAfterAmbiguousCommitAndFailedConfirmation() throws Exception {
+ Fixture fixture = fixture();
+ Connection firstSettlement = mock(Connection.class);
+ Connection unavailableConfirmation = mock(Connection.class);
+ Connection confirmedSettlement = mock(Connection.class);
+ PreparedStatement firstSelect = mock(PreparedStatement.class);
+ PreparedStatement credit = mock(PreparedStatement.class);
+ PreparedStatement firstUpdate = mock(PreparedStatement.class);
+ PreparedStatement confirmedSelect = mock(PreparedStatement.class);
+ ResultSet hookStarted = row("HOOK_STARTED", "owner-settle");
+ ResultSet completed = row("COMPLETED", "owner-settle");
+ when(firstSettlement.prepareStatement(anyString())).thenReturn(firstSelect, credit, firstUpdate);
+ when(firstSelect.executeQuery()).thenReturn(hookStarted);
+ when(credit.executeUpdate()).thenReturn(1);
+ when(firstUpdate.executeUpdate()).thenReturn(1);
+ doThrow(new java.sql.SQLException("ack lost")).when(firstSettlement).commit();
+ when(confirmedSettlement.prepareStatement(anyString())).thenReturn(confirmedSelect);
+ when(confirmedSelect.executeQuery()).thenReturn(completed);
+ when(unavailableConfirmation.prepareStatement(anyString()))
+ .thenThrow(new java.sql.SQLException("confirmation unavailable"));
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, firstSettlement)
+ .thenReturn(unavailableConfirmation, confirmedSettlement);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.settleWithConfirmation("transfer-settle", "owner-settle", "source", "Points", "target",
+ "Points", 10, 4) == SharedPointTransferJournal.SettlementOutcome.COMPLETED);
+
+ verify(credit).executeUpdate();
+ verify(confirmedSelect).executeQuery();
+ }
+
+ @Test
+ void settlementRemainsIndeterminateWhenTheSameTransferCannotBeReconfirmed() throws Exception {
+ Fixture fixture = fixture();
+ Connection unavailable = mock(Connection.class);
+ when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("database unavailable"));
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, unavailable, unavailable,
+ unavailable);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ assertTrue(journal.settleWithConfirmation("transfer-unknown", "owner", "source", "Points", "target", "Points",
+ 10, 4) == SharedPointTransferJournal.SettlementOutcome.INDETERMINATE);
+ }
+
+ @Test
+ void recoveryRefundsAnExpiredReservationUsingItsPersistedSourceColumn() throws Exception {
+ Fixture fixture = fixture();
+ Connection reservedCandidates = mock(Connection.class);
+ Connection recovery = mock(Connection.class);
+ Connection cleanup = mock(Connection.class);
+ PreparedStatement reservedCandidateQuery = mock(PreparedStatement.class);
+ PreparedStatement recoverySelect = mock(PreparedStatement.class);
+ PreparedStatement recoveryRefund = mock(PreparedStatement.class);
+ PreparedStatement recoveryUpdate = mock(PreparedStatement.class);
+ PreparedStatement cleanupSelect = mock(PreparedStatement.class);
+ PreparedStatement cleanupDelete = mock(PreparedStatement.class);
+ ResultSet expiredReservation = ids("expired-reservation");
+ ResultSet reservedRecovery = recoveryRow("RESERVED", 1L, "source", "Points", 10);
+ ResultSet noCleanupCandidates = ids();
+ when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery);
+ when(reservedCandidateQuery.executeQuery()).thenReturn(expiredReservation);
+ when(recovery.prepareStatement(anyString())).thenReturn(recoverySelect, recoveryRefund, recoveryUpdate);
+ when(recoverySelect.executeQuery()).thenReturn(reservedRecovery);
+ when(recoveryRefund.executeUpdate()).thenReturn(1);
+ when(recoveryUpdate.executeUpdate()).thenReturn(1);
+ when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete);
+ when(cleanupSelect.executeQuery()).thenReturn(noCleanupCandidates);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, reservedCandidates, recovery,
+ cleanup);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ var refunded = journal.recoverAndCleanup(SharedPointTransferJournal.RESERVED_RECOVERY_AGE_MILLIS + 2L);
+
+ verify(recoveryRefund).setString(2, "source");
+ verify(recoveryRefund).setInt(1, 10);
+ verify(recoveryUpdate).setString(1, "REFUNDED");
+ verify(recovery).commit();
+ assertEquals(1, refunded.size());
+ assertEquals("source", refunded.get(0).uuid());
+ assertEquals("Points", refunded.get(0).pointsColumn());
+ }
+
+ @Test
+ void recoveryRefundsACompensatingTransferImmediatelyUsingItsPersistedSourceColumn() throws Exception {
+ Fixture fixture = fixture();
+ Connection recoverableCandidates = mock(Connection.class);
+ Connection recovery = mock(Connection.class);
+ Connection cleanup = mock(Connection.class);
+ PreparedStatement recoverableCandidateQuery = mock(PreparedStatement.class);
+ PreparedStatement recoverySelect = mock(PreparedStatement.class);
+ PreparedStatement recoveryRefund = mock(PreparedStatement.class);
+ PreparedStatement recoveryUpdate = mock(PreparedStatement.class);
+ PreparedStatement cleanupSelect = mock(PreparedStatement.class);
+ PreparedStatement cleanupDelete = mock(PreparedStatement.class);
+ ResultSet expiredCompensation = ids("expired-compensation");
+ ResultSet compensationRecovery = recoveryRow("COMPENSATING", 1L, "source", "Points", 10);
+ ResultSet noCleanupCandidates = ids();
+ when(recoverableCandidates.prepareStatement(anyString())).thenReturn(recoverableCandidateQuery);
+ when(recoverableCandidateQuery.executeQuery()).thenReturn(expiredCompensation);
+ when(recovery.prepareStatement(anyString())).thenReturn(recoverySelect, recoveryRefund, recoveryUpdate);
+ when(recoverySelect.executeQuery()).thenReturn(compensationRecovery);
+ when(recoveryRefund.executeUpdate()).thenReturn(1);
+ when(recoveryUpdate.executeUpdate()).thenReturn(1);
+ when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete);
+ when(cleanupSelect.executeQuery()).thenReturn(noCleanupCandidates);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, recoverableCandidates,
+ recovery, cleanup);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ var refunded = journal.recoverAndCleanup(0L);
+
+ verify(recoverableCandidateQuery).setString(3, "COMPENSATING");
+ verify(recoveryRefund).setString(2, "source");
+ verify(recoveryUpdate).setString(1, "REFUNDED");
+ verify(recovery).commit();
+ assertEquals(1, refunded.size());
+ }
+
+ @Test
+ void recoveryRechecksAndNeverRefundsAHookStartedRow() throws Exception {
+ Fixture fixture = fixture();
+ Connection reservedCandidates = mock(Connection.class);
+ Connection recovery = mock(Connection.class);
+ Connection cleanup = mock(Connection.class);
+ PreparedStatement reservedCandidateQuery = mock(PreparedStatement.class);
+ PreparedStatement recoverySelect = mock(PreparedStatement.class);
+ PreparedStatement recoveryRefund = mock(PreparedStatement.class);
+ PreparedStatement cleanupSelect = mock(PreparedStatement.class);
+ PreparedStatement cleanupDelete = mock(PreparedStatement.class);
+ ResultSet claimedCandidate = ids("claimed-transfer");
+ ResultSet claimedHook = recoveryRow("HOOK_STARTED", 1L, "source", "Points", 10);
+ ResultSet noCleanupCandidates = ids();
+ when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery);
+ when(reservedCandidateQuery.executeQuery()).thenReturn(claimedCandidate);
+ when(recovery.prepareStatement(anyString())).thenReturn(recoverySelect, recoveryRefund);
+ when(recoverySelect.executeQuery()).thenReturn(claimedHook);
+ when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete);
+ when(cleanupSelect.executeQuery()).thenReturn(noCleanupCandidates);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, reservedCandidates,
+ recovery, cleanup);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ journal.recoverAndCleanup(1L);
+
+ verify(recoveryRefund, org.mockito.Mockito.never()).executeUpdate();
+ }
+
+ @Test
+ void cleanupDeletesOnlyTheSelectedBoundedTerminalRows() throws Exception {
+ Fixture fixture = fixture();
+ Connection reservedCandidates = mock(Connection.class);
+ Connection cleanup = mock(Connection.class);
+ PreparedStatement reservedCandidateQuery = mock(PreparedStatement.class);
+ PreparedStatement cleanupSelect = mock(PreparedStatement.class);
+ PreparedStatement cleanupDelete = mock(PreparedStatement.class);
+ ResultSet noReservedCandidates = ids();
+ ResultSet oldCompleted = ids("old-completed");
+ when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery);
+ when(reservedCandidateQuery.executeQuery()).thenReturn(noReservedCandidates);
+ when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete);
+ when(cleanupSelect.executeQuery()).thenReturn(oldCompleted);
+ when(cleanupDelete.executeUpdate()).thenReturn(1);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, reservedCandidates, cleanup);
+
+ SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table);
+ journal.recoverAndCleanup(SharedPointTransferJournal.TERMINAL_RETENTION_MILLIS + 2L);
+
+ verify(cleanupDelete).setString(1, "old-completed");
+ verify(cleanupSelect).setInt(4, 100);
+ verify(cleanupDelete).executeUpdate();
+ }
+
+ private static ResultSet row(String state, String owner) throws Exception {
+ ResultSet row = mock(ResultSet.class);
+ when(row.next()).thenReturn(true);
+ when(row.getString(1)).thenReturn(state);
+ when(row.getString(2)).thenReturn(owner);
+ return row;
+ }
+
+ private static ResultSet transferRow(String source, String target, int debit, int requestedCredit, String state)
+ throws Exception {
+ ResultSet row = mock(ResultSet.class);
+ when(row.next()).thenReturn(true);
+ when(row.getString(1)).thenReturn(source);
+ when(row.getString(2)).thenReturn(target);
+ when(row.getInt(3)).thenReturn(debit);
+ when(row.getInt(4)).thenReturn(requestedCredit);
+ when(row.getString(5)).thenReturn(state);
+ return row;
+ }
+
+ private static ResultSet ids(String... transferIds) throws Exception {
+ ResultSet rows = mock(ResultSet.class);
+ Boolean[] next = new Boolean[transferIds.length + 1];
+ for (int index = 0; index < transferIds.length; index++) {
+ next[index] = Boolean.TRUE;
+ }
+ next[transferIds.length] = Boolean.FALSE;
+ when(rows.next()).thenReturn(next[0], java.util.Arrays.copyOfRange(next, 1, next.length));
+ for (int index = 0; index < transferIds.length; index++) {
+ when(rows.getString(1)).thenReturn(transferIds[index]);
+ }
+ return rows;
+ }
+
+ private static ResultSet recoveryRow(String state, long createdAt, String sourceUuid, String sourceColumn,
+ int debitPoints) throws Exception {
+ ResultSet row = mock(ResultSet.class);
+ when(row.next()).thenReturn(true);
+ when(row.getString(1)).thenReturn(state);
+ when(row.getLong(2)).thenReturn(createdAt);
+ when(row.getString(3)).thenReturn(sourceUuid);
+ when(row.getString(4)).thenReturn(sourceColumn);
+ when(row.getInt(5)).thenReturn(debitPoints);
+ return row;
+ }
+
+ private static Fixture fixture() throws Exception {
+ Fixture fixture = new Fixture();
+ fixture.table = mock(MySQL.class);
+ fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class,
+ org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ fixture.schema = mock(Connection.class);
+ fixture.lookup = mock(Connection.class);
+ fixture.reservation = mock(Connection.class);
+ when(fixture.table.getTableName()).thenReturn("VotingPlugin_Users");
+ when(fixture.table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`");
+ when(fixture.table.getMysql()).thenReturn(fixture.sql);
+ when(fixture.sql.getConnectionManager().getConnection()).thenReturn(fixture.schema, fixture.lookup,
+ fixture.reservation);
+ when(fixture.schema.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class));
+ return fixture;
+ }
+
+ private static final class Fixture {
+ MySQL table;
+ com.bencodez.simpleapi.sql.mysql.MySQL sql;
+ Connection schema;
+ Connection lookup;
+ Connection reservation;
+ }
+}
diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java
new file mode 100644
index 0000000000..14c3c66d8d
--- /dev/null
+++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java
@@ -0,0 +1,2003 @@
+package com.bencodez.votingplugin.user;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.CALLS_REAL_METHODS;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+import java.lang.reflect.Field;
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.util.HashMap;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicReference;
+
+import org.bukkit.entity.Player;
+import org.bukkit.Bukkit;
+import org.bukkit.plugin.PluginManager;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.ArgumentCaptor;
+import org.mockito.InOrder;
+import org.mockito.MockedStatic;
+
+import com.bencodez.advancedcore.api.user.UserStorage;
+import com.bencodez.advancedcore.api.user.UserData;
+import com.bencodez.advancedcore.api.user.UserDataFetchMode;
+import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL;
+import com.bencodez.advancedcore.api.user.usercache.UserDataCache;
+import com.bencodez.simpleapi.sql.mysql.ConnectionManager;
+import com.bencodez.simpleapi.scheduler.BukkitScheduler;
+import com.bencodez.simpleapi.folialib.FoliaLib;
+import com.bencodez.simpleapi.folialib.enums.EntityTaskResult;
+import com.bencodez.simpleapi.folialib.impl.ServerImplementation;
+import com.bencodez.votingplugin.VotingPluginMain;
+import com.bencodez.votingplugin.events.PlayerReceivePointsEvent;
+
+class VotingPluginUserPointSchedulingTest {
+ @Test
+ void bulkPointOperationIdsAreDeterministicDistinctAndFitTheJournalSchema() {
+ String first = VotingPluginUser.bulkPointOperationId("admin-bulk-points/", "batch", "player-a");
+ String retry = VotingPluginUser.bulkPointOperationId("admin-bulk-points/", "batch", "player-a");
+ String other = VotingPluginUser.bulkPointOperationId("admin-bulk-points/", "batch", "player-b");
+
+ assertEquals(first, retry);
+ assertFalse(first.equals(other));
+ assertTrue(first.length() <= 64);
+ }
+
+ @Test
+ void ordinaryBulkPointArithmeticUsesAuthoritativeUserReads() {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ VotingPluginUser user = mock(VotingPluginUser.class);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+
+ VotingPluginUser.addPointsStorageAware(plugin, java.util.List.of(user), 5,
+ (ignored, success) -> { });
+ VotingPluginUser.setPointsStorageAware(plugin, java.util.List.of(user), 11,
+ (ignored, success) -> { });
+ VotingPluginUser.removePointsStorageAware(plugin, java.util.List.of(user), 3,
+ (ignored, success) -> { });
+
+ verify(user, org.mockito.Mockito.times(2)).userDataFetechMode(UserDataFetchMode.NO_CACHE);
+ verify(user).addPointsStorageAware(eq(5), org.mockito.ArgumentMatchers.>any());
+ verify(user).setPoints(11);
+ verify(user).removePoints(eq(3), org.mockito.ArgumentMatchers.>any());
+ }
+ @Test
+ void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception {
+ PointFixture fixture = pointFixture();
+ VotingPluginUser second = mock(VotingPluginUser.class);
+ java.util.List users = java.util.List.of(fixture.user, second);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class));
+ VotingPluginUser.addPointsStorageAware(fixture.plugin, users, 5, (user, success) -> { });
+ VotingPluginUser.setPointsStorageAware(fixture.plugin, users, 42, (user, success) -> { });
+ VotingPluginUser.removePointsStorageAware(fixture.plugin, users, 3, (user, success) -> { });
+ }
+
+ verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(any(Runnable.class));
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ }
+
+ @Test
+ void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception {
+ PointFixture fixture = pointFixture();
+ VotingPluginUser second = mock(VotingPluginUser.class);
+ Player secondPlayer = mock(Player.class);
+ when(second.getPlayer()).thenReturn(secondPlayer);
+ java.util.List results = new java.util.ArrayList<>();
+ doThrow(new RejectedExecutionException()).when(fixture.persistence).execute(any(Runnable.class));
+ doAnswer(invocation -> {
+ invocation.getArgument(1).run();
+ return null;
+ }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), any(Player.class));
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class));
+ VotingPluginUser.addPointsStorageAware(fixture.plugin, java.util.List.of(fixture.user, second), 5,
+ (user, success) -> results.add(success));
+ }
+
+ assertEquals(java.util.List.of(false, false), results);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player));
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(secondPlayer));
+ }
+
+ @Test
+ void sharedBulkMutationCapturesPlayersBeforePersistenceWork() throws Exception {
+ PointFixture fixture = pointFixture();
+ VotingPluginUser second = mock(VotingPluginUser.class);
+ Player secondPlayer = mock(Player.class);
+ when(second.getPlayer()).thenReturn(secondPlayer);
+ PluginManager pluginManager = mock(PluginManager.class);
+ doAnswer(invocation -> {
+ invocation.getArgument(0).setCancelled(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class));
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ VotingPluginUser.addPointsStorageAware(fixture.plugin, java.util.List.of(fixture.user, second), 5,
+ (user, success) -> { });
+ }
+
+ verify(fixture.user).getPlayer();
+ verify(second).getPlayer();
+ org.mockito.Mockito.clearInvocations(fixture.user, second);
+ ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistence.capture());
+ persistence.getValue().run();
+ verify(fixture.user, never()).getPlayer();
+ verify(second, never()).getPlayer();
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player));
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(secondPlayer));
+ }
+
+ @Test
+ void sharedBulkAddPreservesPerUserCancellationBeforePersistence() throws Exception {
+ PointFixture fixture = pointFixture();
+ java.util.List results = new java.util.ArrayList<>();
+ PluginManager pluginManager = mock(PluginManager.class);
+ doAnswer(invocation -> {
+ invocation.getArgument(0).setCancelled(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class));
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ VotingPluginUser.addPointsStorageAware(fixture.plugin, java.util.List.of(fixture.user), 5,
+ (user, success) -> results.add(success));
+ }
+
+ ArgumentCaptor persistenceTask = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceTask.capture());
+ persistenceTask.getValue().run();
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class));
+ }
+
+ @Test
+ void sharedBulkPointMutationResubmitsBoundedChunks() throws Exception {
+ PointFixture fixture = pointFixture();
+ PluginManager pluginManager = mock(PluginManager.class);
+ doAnswer(invocation -> {
+ invocation.getArgument(0).setCancelled(true);
+ return null;
+ }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class));
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ VotingPluginUser.addPointsStorageAware(fixture.plugin,
+ java.util.Collections.nCopies(65, fixture.user), 5, (user, success) -> { });
+ }
+
+ ArgumentCaptor persistenceTasks = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceTasks.capture());
+ persistenceTasks.getValue().run();
+ verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(persistenceTasks.capture());
+ persistenceTasks.getAllValues().get(persistenceTasks.getAllValues().size() - 1).run();
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ verify(fixture.scheduler, org.mockito.Mockito.times(65)).runTask(eq(fixture.plugin), any(Runnable.class),
+ eq(fixture.player));
+ }
+
+ @Test
+ void storageAwareSetDoesNotUseJdbcOnCallerThread() throws Exception {
+ PointFixture fixture = pointFixture();
+ java.util.List results = new java.util.ArrayList<>();
+
+ fixture.user.setPointsStorageAware(42, results::add);
+
+ ArgumentCaptor persistenceTask = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceTask.capture());
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ }
+
+ @Test
+ void storageAwareAddStaysSynchronousOutsideSharedMysql() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+ VotingPluginUser user = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
+ Field pluginField = VotingPluginUser.class.getDeclaredField("plugin");
+ pluginField.setAccessible(true);
+ pluginField.set(user, plugin);
+ doReturn(15).when(user).addPoints(10, false);
+
+ assertEquals(15, user.addPointsStorageAware(10));
+
+ verify(user).addPoints(10, false);
+ verify(user, never()).addPoints(10, true);
+ }
+
+ @Test
+ void nonSharedTransferCreditsBeforeReportingSuccess() throws Exception {
+ VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS);
+ when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE);
+ VotingPluginUser source = mock(VotingPluginUser.class, CALLS_REAL_METHODS);
+ VotingPluginUser target = mock(VotingPluginUser.class);
+ Field pluginField = VotingPluginUser.class.getDeclaredField("plugin");
+ pluginField.setAccessible(true);
+ pluginField.set(source, plugin);
+ doReturn(true).when(source).removePoints(10);
+ AtomicReference result = new AtomicReference<>();
+
+ source.transferPoints(target, 10, result::set);
+
+ InOrder order = inOrder(source, target);
+ order.verify(source).removePoints(10);
+ order.verify(target).addPoints(10);
+ assertEquals(Boolean.TRUE, result.get());
+ }
+
+ @Test
+ void votePointAwardQueuesSharedMysqlMutationOffTheServerLane() throws Exception {
+ PointFixture fixture = pointFixture();
+ UserData data = mock(UserData.class);
+ doReturn(data).when(fixture.user).getUserData();
+ when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(10);
+ when(fixture.plugin.getConfigFile().getPointsOnVote()).thenReturn(5);
+ when(fixture.plugin.getConfigFile().getLimitVotePoints()).thenReturn(0);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ PluginManager pluginManager = mock(PluginManager.class);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ fixture.user.addPoints();
+ }
+
+ verify(fixture.persistence).execute(any(Runnable.class));
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ }
+
+ @Test
+ void votePointAwardCombinesSharedAdditionAndCapInOnePersistenceTask() throws Exception {
+ PointFixture fixture = pointFixture();
+ UserDataCache cache = mock(UserDataCache.class);
+ HashMap values = new HashMap<>();
+ values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(98));
+ doReturn(true).when(fixture.user).isCached();
+ doReturn(cache).when(fixture.user).getCache();
+ when(cache.getCache()).thenReturn(values);
+ java.util.UUID userUuid = java.util.UUID.fromString("00000000-0000-0000-0000-000000000001");
+ when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(
+ new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(userUuid, cache)));
+ when(fixture.plugin.getConfigFile().getPointsOnVote()).thenReturn(5);
+ when(fixture.plugin.getConfigFile().getLimitVotePoints()).thenReturn(100);
+ PluginManager pluginManager = mock(PluginManager.class);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ fixture.user.addPoints();
+ }
+ assertEquals(100, values.get("Points").getInt());
+
+ ArgumentCaptor persistenceTask = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceTask.capture());
+ verify(fixture.persistence, org.mockito.Mockito.times(1)).execute(any(Runnable.class));
+ persistenceTask.getValue().run();
+ assertFalse(values.containsKey("Points"));
+
+ verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.argThat(
+ query -> query.contains("`Points` = LEAST(`Points` + ?, ?)")));
+ verify(fixture.statement).setInt(1, 5);
+ verify(fixture.statement).setInt(2, 100);
+ }
+
+ @Test
+ void rejectedInitialSharedTransferSubmissionCompletesAsFailure() throws Exception {
+ TransferSchedulingFixture fixture = transferSchedulingFixture();
+ AtomicReference result = new AtomicReference<>();
+ doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class));
+
+ fixture.user.transferPointsWithResult(fixture.target, 10, result::set);
+
+ ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player));
+ verifyNoInteractions(fixture.manager);
+ completion.getValue().run();
+ assertEquals(PointTransferResult.UNAVAILABLE, result.get());
+ }
+
+ @Test
+ void indeterminateSharedTransferClaimDoesNotReportSuccessBeforeApproval(@TempDir Path temporaryDirectory) throws Exception {
+ SagaFixture fixture = sagaFixture(true);
+ when(fixture.plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile());
+ Connection unavailable = mock(Connection.class);
+ when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("unavailable"));
+ when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup,
+ fixture.lookup, fixture.reservation, fixture.claim).thenAnswer(invocation -> unavailable);
+ doThrow(new java.sql.SQLException("claim acknowledgement lost")).when(fixture.claim).commit();
+ AtomicReference result = new AtomicReference<>();
+
+ fixture.user.transferPoints(fixture.target, 10, result::set);
+ ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistence.capture());
+ persistence.getValue().run();
+ ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), gate.capture());
+ gate.getValue().run();
+ ArgumentCaptor claim = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claim.capture());
+ claim.getAllValues().get(1).run();
+
+ ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player));
+ verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class));
+ completion.getValue().run();
+ assertEquals(Boolean.FALSE, result.get());
+ assertEquals(1, new SharedPointTransferCompensationStore(temporaryDirectory).loadBatch().size());
+ }
+
+ @Test
+ void sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal() throws Exception {
+ PointFixture fixture = pointFixture();
+ UserData data = mock(UserData.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ doReturn(data).when(fixture.user).getUserData();
+ when(fixture.statement.executeUpdate()).thenReturn(1);
+ when(data.getInt("Points", UserDataFetchMode.NO_CACHE)).thenReturn(10);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, read);
+ when(read.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getInt(1)).thenReturn(73);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ PluginManager pluginManager = mock(PluginManager.class);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+
+ assertEquals(73, fixture.user.addPoints(5));
+ }
+
+ InOrder mutationThenRead = inOrder(fixture.statement, read);
+ mutationThenRead.verify(fixture.statement).executeUpdate();
+ mutationThenRead.verify(read).executeQuery();
+ verify(data, never()).getInt("Points", UserDataFetchMode.NO_CACHE);
+ }
+
+ @Test
+ void storageAwareAddReportsOnlyAfterCommittedSharedWrite() throws Exception {
+ PointFixture fixture = pointFixture();
+ UserData data = mock(UserData.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ doReturn(data).when(fixture.user).getUserData();
+ when(fixture.statement.executeUpdate()).thenReturn(1);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, read);
+ when(read.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getInt(1)).thenReturn(23);
+ AtomicReference success = new AtomicReference<>();
+ AtomicReference total = new AtomicReference<>();
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ PluginManager pluginManager = mock(PluginManager.class);
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ fixture.user.addPointsStorageAware(5, (written, committed) -> {
+ success.set(written);
+ total.set(committed);
+ });
+ }
+
+ assertTrue(success.get() == null, "the command callback must wait for persistence");
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceWork.capture());
+ persistenceWork.getValue().run();
+ ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player));
+ completion.getValue().run();
+ assertEquals(Boolean.TRUE, success.get());
+ assertEquals(23, total.get());
+ }
+
+ @Test
+ void storageAwareAsyncStageWaitsForCommittedSharedWrite() throws Exception {
+ PointFixture fixture = pointFixture();
+ UserData data = mock(UserData.class);
+ PreparedStatement read = mock(PreparedStatement.class);
+ ResultSet result = mock(ResultSet.class);
+ doReturn(data).when(fixture.user).getUserData();
+ when(fixture.statement.executeUpdate()).thenReturn(1);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, read);
+ when(read.executeQuery()).thenReturn(result);
+ when(result.next()).thenReturn(true);
+ when(result.getInt(1)).thenReturn(23);
+ CompletableFuture completion;
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class));
+ completion = fixture.user.addPointsStorageAwareAsync(5).toCompletableFuture();
+ }
+
+ assertFalse(completion.isDone(), "the reward stage must wait for persistence");
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceWork.capture());
+ persistenceWork.getValue().run();
+ assertEquals(23, completion.join());
+ verifyNoInteractions(fixture.scheduler);
+ }
+
+ @Test
+ void durableSharedAsyncRetryCompletesBeforeFiringTheReceiveEvent() throws Exception {
+ PointFixture fixture = pointFixture();
+ PreparedStatement createTable = mock(PreparedStatement.class);
+ PreparedStatement createIndex = mock(PreparedStatement.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet completed = mock(ResultSet.class);
+ when(completed.next()).thenReturn(true);
+ when(completed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(completed.getString(2)).thenReturn("Points");
+ when(completed.getInt(3)).thenReturn(7);
+ when(completed.getString(4)).thenReturn("COMPLETED");
+ when(completed.getObject(5)).thenReturn(Integer.valueOf(23));
+ when(completed.getInt(5)).thenReturn(23);
+ when(lookup.executeQuery()).thenReturn(completed);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(createTable, createIndex, lookup);
+ PluginManager pluginManager = mock(PluginManager.class);
+ CompletableFuture completion;
+ CompletableFuture concurrentRetry;
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation").toCompletableFuture();
+ concurrentRetry = fixture.user.addPointsStorageAwareAsync(5, "reward-operation").toCompletableFuture();
+ }
+
+ assertFalse(completion.isDone());
+ assertEquals(completion, concurrentRetry);
+ verify(fixture.sql.getConnectionManager(), never()).getConnection();
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence, org.mockito.Mockito.times(1)).execute(persistenceWork.capture());
+ persistenceWork.getValue().run();
+ assertEquals(23, completion.join());
+ verifyNoInteractions(pluginManager);
+ verifyNoInteractions(fixture.scheduler);
+ }
+
+ @Test
+ void durableSharedAsyncRetrySurvivesSwitchToPerServerPoints() throws Exception {
+ PointFixture fixture = pointFixture();
+ when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ doReturn("lobby_Points").when(fixture.user).getPointsPath();
+ PreparedStatement createTable = mock(PreparedStatement.class);
+ PreparedStatement createIndex = mock(PreparedStatement.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ ResultSet completed = mock(ResultSet.class);
+ when(completed.next()).thenReturn(true);
+ when(completed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(completed.getString(2)).thenReturn("Points");
+ when(completed.getInt(3)).thenReturn(7);
+ when(completed.getString(4)).thenReturn("COMPLETED");
+ when(completed.getObject(5)).thenReturn(Integer.valueOf(23));
+ when(completed.getInt(5)).thenReturn(23);
+ when(lookup.executeQuery()).thenReturn(completed);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(createTable, createIndex, lookup);
+ PluginManager pluginManager = mock(PluginManager.class);
+ CompletableFuture completion;
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation").toCompletableFuture();
+ }
+
+ assertFalse(completion.isDone());
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceWork.capture());
+ persistenceWork.getValue().run();
+ assertEquals(23, completion.join());
+ verifyNoInteractions(pluginManager);
+ verifyNoInteractions(fixture.scheduler);
+ verify(fixture.user, never()).setPoints(anyInt(), eq(false));
+ }
+
+ @Test
+ void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() throws Exception {
+ PointFixture fixture = pointFixture();
+ when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ doReturn("1Lobby West_Points").when(fixture.user).getPointsPath();
+ PreparedStatement schema = mock(PreparedStatement.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ PreparedStatement claimInsert = mock(PreparedStatement.class);
+ PreparedStatement settleSelect = mock(PreparedStatement.class);
+ PreparedStatement settleCredit = mock(PreparedStatement.class);
+ PreparedStatement settleRead = mock(PreparedStatement.class);
+ PreparedStatement settleComplete = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ AtomicReference owner = new AtomicReference<>();
+ doAnswer(invocation -> {
+ owner.set(invocation.getArgument(1));
+ return null;
+ }).when(claimInsert).setString(eq(8), anyString());
+ ResultSet claimed = mock(ResultSet.class);
+ when(claimed.next()).thenReturn(true);
+ when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(claimed.getString(2)).thenReturn("Points");
+ when(claimed.getInt(3)).thenReturn(5);
+ when(claimed.getString(4)).thenReturn("HOOK_STARTED");
+ when(claimed.getObject(5)).thenReturn(null);
+ when(claimed.getObject(6)).thenReturn(Integer.valueOf(5));
+ when(claimed.getInt(6)).thenReturn(5);
+ when(claimed.getString(7)).thenAnswer(invocation -> owner.get());
+ when(settleSelect.executeQuery()).thenReturn(claimed);
+ when(settleCredit.executeUpdate()).thenReturn(1);
+ ResultSet completedTotal = mock(ResultSet.class);
+ when(completedTotal.next()).thenReturn(true);
+ when(completedTotal.getInt(1)).thenReturn(15);
+ when(settleRead.executeQuery()).thenReturn(completedTotal);
+ when(settleComplete.executeUpdate()).thenReturn(1);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, schema, schema, lookup,
+ claimInsert, settleSelect, settleCredit, settleRead, settleComplete);
+ PluginManager pluginManager = mock(PluginManager.class);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ CompletableFuture completion = fixture.user
+ .addPointsStorageAwareAsync(5, "per-server-operation").toCompletableFuture();
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceWork.capture());
+ verifyNoInteractions(pluginManager);
+ verify(fixture.user, never()).setPoints(anyInt(), eq(false));
+
+ persistenceWork.getValue().run();
+ ArgumentCaptor bukkitWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), bukkitWork.capture(), eq(fixture.player));
+ assertFalse(completion.isDone());
+ verifyNoInteractions(pluginManager);
+ verify(fixture.user, never()).setPoints(anyInt(), eq(false));
+
+ bukkitWork.getValue().run();
+ assertFalse(completion.isDone());
+ verify(fixture.table, never()).checkColumn("1Lobby West_Points", com.bencodez.simpleapi.sql.DataType.INTEGER);
+ ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture());
+ settlementWork.getAllValues().get(1).run();
+ assertEquals(15, completion.join());
+ verify(pluginManager).callEvent(org.mockito.ArgumentMatchers.argThat(event ->
+ event instanceof PlayerReceivePointsEvent && !event.isAsynchronous()));
+ org.mockito.InOrder settlementOrder = org.mockito.Mockito.inOrder(fixture.table, settleCredit);
+ settlementOrder.verify(fixture.table).checkColumn("1Lobby West_Points", com.bencodez.simpleapi.sql.DataType.INTEGER);
+ settlementOrder.verify(settleCredit).executeUpdate();
+ verify(settleCredit).setInt(1, 5);
+ verify(settleCredit).setString(2, "00000000-0000-0000-0000-000000000001");
+ verify(claimInsert).setString(3, "Points");
+ verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.contains("`1Lobby West_Points` = COALESCE(`1Lobby West_Points`, 0) + ?"));
+ }
+ }
+
+ @Test
+ void perServerPointAdditionDoesNotSettleJournalBeforeLocalPersistenceCompletes() throws Exception {
+ PointFixture fixture = pointFixture();
+ when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ doReturn("lobby_Points").when(fixture.user).getPointsPath();
+ CountDownLatch localCreditStarted = new CountDownLatch(1);
+ CountDownLatch releaseLocalCredit = new CountDownLatch(1);
+ PreparedStatement schema = mock(PreparedStatement.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ PreparedStatement claimInsert = mock(PreparedStatement.class);
+ PreparedStatement settleSelect = mock(PreparedStatement.class);
+ PreparedStatement settleCredit = mock(PreparedStatement.class);
+ PreparedStatement settleRead = mock(PreparedStatement.class);
+ PreparedStatement settleComplete = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ AtomicReference owner = new AtomicReference<>();
+ doAnswer(invocation -> {
+ owner.set(invocation.getArgument(1));
+ return null;
+ }).when(claimInsert).setString(eq(8), anyString());
+ ResultSet claimed = mock(ResultSet.class);
+ when(claimed.next()).thenReturn(true);
+ when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(claimed.getString(2)).thenReturn("Points");
+ when(claimed.getInt(3)).thenReturn(5);
+ when(claimed.getString(4)).thenReturn("HOOK_STARTED");
+ when(claimed.getObject(5)).thenReturn(null);
+ when(claimed.getObject(6)).thenReturn(Integer.valueOf(5));
+ when(claimed.getInt(6)).thenReturn(5);
+ when(claimed.getString(7)).thenAnswer(invocation -> owner.get());
+ when(settleSelect.executeQuery()).thenReturn(claimed);
+ doAnswer(invocation -> {
+ localCreditStarted.countDown();
+ assertTrue(releaseLocalCredit.await(5, TimeUnit.SECONDS));
+ throw new java.sql.SQLException("local credit failed");
+ }).when(settleCredit).executeUpdate();
+ when(settleComplete.executeUpdate()).thenReturn(1);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, schema, schema, lookup,
+ claimInsert, settleSelect, settleCredit, settleRead, settleComplete);
+ PluginManager pluginManager = mock(PluginManager.class);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ CompletableFuture completion = fixture.user
+ .addPointsStorageAwareAsync(5, "per-server-operation").toCompletableFuture();
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceWork.capture());
+ persistenceWork.getValue().run();
+
+ ArgumentCaptor bukkitWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.scheduler).runTask(eq(fixture.plugin), bukkitWork.capture(), eq(fixture.player));
+ bukkitWork.getValue().run();
+
+ ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture());
+ CompletableFuture runningSettlement = CompletableFuture.runAsync(settlementWork.getAllValues().get(1));
+ assertTrue(localCreditStarted.await(5, TimeUnit.SECONDS));
+ verify(settleComplete, never()).executeUpdate();
+ releaseLocalCredit.countDown();
+ runningSettlement.get(5, TimeUnit.SECONDS);
+
+ assertTrue(completion.isCompletedExceptionally());
+ verify(settleComplete, never()).executeUpdate();
+ verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class));
+ }
+ }
+
+ @Test
+ void perServerPointAdditionFailsWithoutWritingWhenBukkitHandoffIsRejected() throws Exception {
+ PointFixture fixture = pointFixture();
+ when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true);
+ doReturn("lobby_Points").when(fixture.user).getPointsPath();
+ PreparedStatement schema = mock(PreparedStatement.class);
+ PreparedStatement lookup = mock(PreparedStatement.class);
+ PreparedStatement claimInsert = mock(PreparedStatement.class);
+ PreparedStatement releaseSelect = mock(PreparedStatement.class);
+ PreparedStatement releaseDelete = mock(PreparedStatement.class);
+ ResultSet missing = mock(ResultSet.class);
+ when(missing.next()).thenReturn(false);
+ when(lookup.executeQuery()).thenReturn(missing);
+ AtomicReference owner = new AtomicReference<>();
+ doAnswer(invocation -> {
+ owner.set(invocation.getArgument(1));
+ return null;
+ }).when(claimInsert).setString(eq(8), anyString());
+ ResultSet claimed = mock(ResultSet.class);
+ when(claimed.next()).thenReturn(true);
+ when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(claimed.getString(2)).thenReturn("Points");
+ when(claimed.getInt(3)).thenReturn(5);
+ when(claimed.getString(4)).thenReturn("HOOK_STARTED");
+ when(claimed.getObject(5)).thenReturn(null);
+ when(claimed.getObject(6)).thenReturn(Integer.valueOf(5));
+ when(claimed.getInt(6)).thenReturn(5);
+ when(claimed.getString(7)).thenAnswer(invocation -> owner.get());
+ when(releaseSelect.executeQuery()).thenReturn(claimed);
+ when(releaseDelete.executeUpdate()).thenReturn(1);
+ when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, schema, schema, lookup,
+ claimInsert, releaseSelect, releaseDelete);
+ when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)))
+ .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED));
+ doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler)
+ .runTask(eq(fixture.plugin), any(Runnable.class));
+ PluginManager pluginManager = mock(PluginManager.class);
+
+ try (MockedStatic bukkit = mockStatic(Bukkit.class)) {
+ bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager);
+ CompletableFuture completion = fixture.user
+ .addPointsStorageAwareAsync(5, "per-server-operation").toCompletableFuture();
+ ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence).execute(persistenceWork.capture());
+ persistenceWork.getValue().run();
+
+ ArgumentCaptor releaseWork = ArgumentCaptor.forClass(Runnable.class);
+ verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(releaseWork.capture());
+ releaseWork.getAllValues().get(1).run();
+ assertTrue(completion.isCompletedExceptionally());
+ verifyNoInteractions(pluginManager);
+ verify(fixture.user, never()).setPoints(anyInt(), eq(false));
+ verify(releaseDelete).setString(2, "HOOK_STARTED");
+ }
+ }
+
+ @Test
+ void retiredEntitySchedulerQueuesUnstartedHookReleaseWithoutJdbcOnCompletionLane() throws Exception {
+ PointFixture fixture = pointFixture();
+ CompletableFuture entityCompletion = new CompletableFuture<>();
+ when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)))
+ .thenReturn(entityCompletion);
+ PreparedStatement statement = fixture.statement;
+ ResultSet missing = mock(ResultSet.class);
+ when(missing.next()).thenReturn(false);
+ ResultSet claimed = mock(ResultSet.class);
+ when(claimed.next()).thenReturn(true);
+ when(claimed.getString(1)).thenReturn("00000000-0000-0000-0000-000000000001");
+ when(claimed.getString(2)).thenReturn("Points");
+ when(claimed.getInt(3)).thenReturn(5);
+ when(claimed.getString(4)).thenReturn("HOOK_STARTED");
+ when(claimed.getObject(5)).thenReturn(null);
+ when(claimed.getObject(6)).thenReturn(Integer.valueOf(5));
+ when(claimed.getInt(6)).thenReturn(5);
+ AtomicReference