From 6b69462cc0ec96ffa9e7c9d39bd4fe314c2a437b Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 15:21:07 -0600 Subject: [PATCH 01/74] Make vote shop debit atomic --- .../service/VoteShopPurchaseService.java | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) 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 e4d91b026..7b6374c79 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 @@ -88,8 +88,9 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot 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; } plugin.getLogger().info("VoteShop: " + user.getPlayerName() + "/" + user.getUUID() + " bought " @@ -108,14 +109,25 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot item.getIdentifier(), item.getCost()); Bukkit.getPluginManager().callEvent(purchaseEvent); - if (item.getLimit() > 0) { - user.setVoteShopIdentifierLimit(item.getIdentifier(), - user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1); - } - return VoteShopPurchaseResult.SUCCESS; } + VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item) { + synchronized (user) { + if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { + return VoteShopPurchaseResult.LIMIT_REACHED; + } + if (!user.removePoints(item.getCost())) { + return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + } + if (item.getLimit() > 0) { + user.setVoteShopIdentifierLimit(item.getIdentifier(), + user.getVoteShopIdentifierLimit(item.getIdentifier()) + 1); + } + return VoteShopPurchaseResult.SUCCESS; + } + } + /** * Checks permission support including inverse permissions with !. * From 4b12ebf96b5c93b8d2a0fd2fe946863109fa93ec Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 15:21:12 -0600 Subject: [PATCH 02/74] Test concurrent vote shop debits --- .../service/VoteShopPurchaseServiceTest.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java new file mode 100644 index 000000000..90f66a401 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -0,0 +1,54 @@ +package com.bencodez.votingplugin.voteshop.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; + +class VoteShopPurchaseServiceTest { + + @Test + void concurrentDebitsCannotSpendTheSameBalanceTwice() throws InterruptedException { + VoteShopPurchaseService service = new VoteShopPurchaseService(null, null); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteShopItem item = mock(VoteShopItem.class); + AtomicInteger balance = new AtomicInteger(10); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + when(user.removePoints(10)).thenAnswer(invocation -> balance.compareAndSet(10, 0)); + + CountDownLatch start = new CountDownLatch(1); + AtomicReference first = new AtomicReference<>(); + AtomicReference second = new AtomicReference<>(); + Thread one = new Thread(() -> runDebit(service, user, item, start, first)); + Thread two = new Thread(() -> runDebit(service, user, item, start, second)); + one.start(); + two.start(); + start.countDown(); + one.join(); + two.join(); + + long successes = java.util.stream.Stream.of(first.get(), second.get()) + .filter(result -> result == VoteShopPurchaseResult.SUCCESS).count(); + assertEquals(1, successes); + assertEquals(0, balance.get()); + } + + private void runDebit(VoteShopPurchaseService service, VotingPluginUser user, VoteShopItem item, + CountDownLatch start, AtomicReference result) { + try { + start.await(); + result.set(service.debitForPurchase(user, item)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} From 1d44c4b73fd0e4f917351aadb38aeacd6c5ac46d Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 16:35:44 -0600 Subject: [PATCH 03/74] Use stable purchase locks and async persistence --- .../service/VoteShopPurchaseService.java | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) 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 7b6374c79..a157791ac 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 @@ -22,6 +22,8 @@ @Getter @Setter public class VoteShopPurchaseService { + private static final int PURCHASE_LOCK_STRIPES = 256; + private final Object[] purchaseLocks = createPurchaseLocks(); private VoteShopDefinition definition; @@ -113,11 +115,11 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot } VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item) { - synchronized (user) { + synchronized (purchaseLock(user.getUUID())) { if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { return VoteShopPurchaseResult.LIMIT_REACHED; } - if (!user.removePoints(item.getCost())) { + if (!user.removePoints(item.getCost(), true)) { return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; } if (item.getLimit() > 0) { @@ -128,6 +130,18 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item } } + private Object purchaseLock(String uuid) { + return purchaseLocks[(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; + } + /** * Checks permission support including inverse permissions with !. * From 70fe42f378e1e6d584f9233bbe50a8cd490f43fa Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 16:35:57 -0600 Subject: [PATCH 04/74] Use stable purchase locks and async persistence --- .../service/VoteShopPurchaseServiceTest.java | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 90f66a401..4c4009598 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.verify; import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; @@ -18,18 +19,33 @@ class VoteShopPurchaseServiceTest { @Test void concurrentDebitsCannotSpendTheSameBalanceTwice() throws InterruptedException { VoteShopPurchaseService service = new VoteShopPurchaseService(null, null); - VotingPluginUser user = mock(VotingPluginUser.class); + VotingPluginUser firstWrapper = mock(VotingPluginUser.class); + VotingPluginUser secondWrapper = mock(VotingPluginUser.class); VoteShopItem item = mock(VoteShopItem.class); - AtomicInteger balance = new AtomicInteger(10); + int[] balance = { 10 }; + CountDownLatch simultaneousReads = new CountDownLatch(2); when(item.getCost()).thenReturn(10); when(item.getLimit()).thenReturn(0); - when(user.removePoints(10)).thenAnswer(invocation -> balance.compareAndSet(10, 0)); + when(firstWrapper.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(secondWrapper.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + org.mockito.stubbing.Answer nonAtomicDebit = invocation -> { + int observed = balance[0]; + simultaneousReads.countDown(); + simultaneousReads.await(100, java.util.concurrent.TimeUnit.MILLISECONDS); + if (observed < 10) { + return false; + } + balance[0] = observed - 10; + return true; + }; + when(firstWrapper.removePoints(10, true)).thenAnswer(nonAtomicDebit); + when(secondWrapper.removePoints(10, true)).thenAnswer(nonAtomicDebit); CountDownLatch start = new CountDownLatch(1); AtomicReference first = new AtomicReference<>(); AtomicReference second = new AtomicReference<>(); - Thread one = new Thread(() -> runDebit(service, user, item, start, first)); - Thread two = new Thread(() -> runDebit(service, user, item, start, second)); + Thread one = new Thread(() -> runDebit(service, firstWrapper, item, start, first)); + Thread two = new Thread(() -> runDebit(service, secondWrapper, item, start, second)); one.start(); two.start(); start.countDown(); @@ -39,7 +55,9 @@ void concurrentDebitsCannotSpendTheSameBalanceTwice() throws InterruptedExceptio long successes = java.util.stream.Stream.of(first.get(), second.get()) .filter(result -> result == VoteShopPurchaseResult.SUCCESS).count(); assertEquals(1, successes); - assertEquals(0, balance.get()); + assertEquals(0, balance[0]); + verify(firstWrapper).removePoints(10, true); + verify(secondWrapper).removePoints(10, true); } private void runDebit(VoteShopPurchaseService service, VotingPluginUser user, VoteShopItem item, From a199326714d7c6172bd3db335cc2603f93863c03 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 16:52:58 -0600 Subject: [PATCH 05/74] Keep purchase locks stable across reloads --- .../voteshop/service/VoteShopPurchaseService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 a157791ac..5f8a23d15 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 @@ -23,7 +23,7 @@ @Setter public class VoteShopPurchaseService { private static final int PURCHASE_LOCK_STRIPES = 256; - private final Object[] purchaseLocks = createPurchaseLocks(); + private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); private VoteShopDefinition definition; @@ -131,7 +131,7 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item } private Object purchaseLock(String uuid) { - return purchaseLocks[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)]; + return PURCHASE_LOCKS[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)]; } private static Object[] createPurchaseLocks() { From e335aec9ad1ec8d6e2516c097b69409137e49389 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 16:53:09 -0600 Subject: [PATCH 06/74] Keep purchase locks stable across reloads --- .../voteshop/service/VoteShopPurchaseServiceTest.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 4c4009598..06c7499cd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -18,7 +18,8 @@ class VoteShopPurchaseServiceTest { @Test void concurrentDebitsCannotSpendTheSameBalanceTwice() throws InterruptedException { - VoteShopPurchaseService service = new VoteShopPurchaseService(null, null); + VoteShopPurchaseService firstService = new VoteShopPurchaseService(null, null); + VoteShopPurchaseService reloadedService = new VoteShopPurchaseService(null, null); VotingPluginUser firstWrapper = mock(VotingPluginUser.class); VotingPluginUser secondWrapper = mock(VotingPluginUser.class); VoteShopItem item = mock(VoteShopItem.class); @@ -44,8 +45,8 @@ void concurrentDebitsCannotSpendTheSameBalanceTwice() throws InterruptedExceptio CountDownLatch start = new CountDownLatch(1); AtomicReference first = new AtomicReference<>(); AtomicReference second = new AtomicReference<>(); - Thread one = new Thread(() -> runDebit(service, firstWrapper, item, start, first)); - Thread two = new Thread(() -> runDebit(service, secondWrapper, item, start, second)); + Thread one = new Thread(() -> runDebit(firstService, firstWrapper, item, start, first)); + Thread two = new Thread(() -> runDebit(reloadedService, secondWrapper, item, start, second)); one.start(); two.start(); start.countDown(); From 9c176fdb2ce1da7ad3713d16ca6e294ccb74b27d Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 17:16:58 -0600 Subject: [PATCH 07/74] Address review feedback with regression coverage --- .../votingplugin/voteshop/service/VoteShopPurchaseService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 5f8a23d15..00eedbbad 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 @@ -130,7 +130,7 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item } } - private Object purchaseLock(String uuid) { + Object purchaseLock(String uuid) { return PURCHASE_LOCKS[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)]; } From dd3a7701e93d125cfc05cebe89e907951394496f Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 17:17:10 -0600 Subject: [PATCH 08/74] Address review feedback with regression coverage --- .../service/VoteShopPurchaseServiceTest.java | 64 ++++--------------- 1 file changed, 14 insertions(+), 50 deletions(-) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 06c7499cd..792c41bc1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -1,14 +1,11 @@ package com.bencodez.votingplugin.voteshop.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - import org.junit.jupiter.api.Test; import com.bencodez.votingplugin.user.VotingPluginUser; @@ -17,57 +14,24 @@ class VoteShopPurchaseServiceTest { @Test - void concurrentDebitsCannotSpendTheSameBalanceTwice() throws InterruptedException { + void wrappersForSamePlayerShareLockAcrossServiceReloads() { VoteShopPurchaseService firstService = new VoteShopPurchaseService(null, null); VoteShopPurchaseService reloadedService = new VoteShopPurchaseService(null, null); - VotingPluginUser firstWrapper = mock(VotingPluginUser.class); - VotingPluginUser secondWrapper = mock(VotingPluginUser.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + assertSame(firstService.purchaseLock(uuid), reloadedService.purchaseLock(uuid)); + } + + @Test + void debitUsesAsynchronousPersistence() { + VoteShopPurchaseService service = new VoteShopPurchaseService(null, null); + VotingPluginUser user = mock(VotingPluginUser.class); VoteShopItem item = mock(VoteShopItem.class); - int[] balance = { 10 }; - CountDownLatch simultaneousReads = new CountDownLatch(2); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); when(item.getCost()).thenReturn(10); when(item.getLimit()).thenReturn(0); - when(firstWrapper.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); - when(secondWrapper.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); - org.mockito.stubbing.Answer nonAtomicDebit = invocation -> { - int observed = balance[0]; - simultaneousReads.countDown(); - simultaneousReads.await(100, java.util.concurrent.TimeUnit.MILLISECONDS); - if (observed < 10) { - return false; - } - balance[0] = observed - 10; - return true; - }; - when(firstWrapper.removePoints(10, true)).thenAnswer(nonAtomicDebit); - when(secondWrapper.removePoints(10, true)).thenAnswer(nonAtomicDebit); - - CountDownLatch start = new CountDownLatch(1); - AtomicReference first = new AtomicReference<>(); - AtomicReference second = new AtomicReference<>(); - Thread one = new Thread(() -> runDebit(firstService, firstWrapper, item, start, first)); - Thread two = new Thread(() -> runDebit(reloadedService, secondWrapper, item, start, second)); - one.start(); - two.start(); - start.countDown(); - one.join(); - two.join(); - - long successes = java.util.stream.Stream.of(first.get(), second.get()) - .filter(result -> result == VoteShopPurchaseResult.SUCCESS).count(); - assertEquals(1, successes); - assertEquals(0, balance[0]); - verify(firstWrapper).removePoints(10, true); - verify(secondWrapper).removePoints(10, true); - } + when(user.removePoints(10, true)).thenReturn(true); - private void runDebit(VoteShopPurchaseService service, VotingPluginUser user, VoteShopItem item, - CountDownLatch start, AtomicReference result) { - try { - start.await(); - result.set(service.debitForPurchase(user, item)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + assertEquals(VoteShopPurchaseResult.SUCCESS, service.debitForPurchase(user, item)); + verify(user).removePoints(10, true); } } From 6a5cd23d90534f368d5efe0ed9ba0454d2ae6512 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 7 Sep 2026 17:52:32 -0600 Subject: [PATCH 09/74] Address latest review feedback --- .../service/VoteShopPurchaseServiceTest.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 792c41bc1..adc9e0b7e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -5,6 +5,14 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.times; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.Test; @@ -34,4 +42,46 @@ void debitUsesAsynchronousPersistence() { assertEquals(VoteShopPurchaseResult.SUCCESS, service.debitForPurchase(user, item)); verify(user).removePoints(10, true); } + + @Test + void concurrentWrappersCannotDebitSamePlayerTogether() throws Exception { + VoteShopPurchaseService first = new VoteShopPurchaseService(null, null); + VoteShopPurchaseService second = new VoteShopPurchaseService(null, null); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteShopItem item = mock(VoteShopItem.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + CountDownLatch firstInsideDebit = new CountDownLatch(1); + CountDownLatch releaseFirst = new CountDownLatch(1); + AtomicInteger calls = new AtomicInteger(); + when(user.removePoints(10, true)).thenAnswer(invocation -> { + if (calls.incrementAndGet() == 1) { + firstInsideDebit.countDown(); + releaseFirst.await(5, TimeUnit.SECONDS); + return true; + } + return false; + }); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future one = executor.submit(() -> first.debitForPurchase(user, item)); + assertEquals(true, firstInsideDebit.await(5, TimeUnit.SECONDS)); + CountDownLatch secondStarted = new CountDownLatch(1); + Future two = executor.submit(() -> { + secondStarted.countDown(); + return second.debitForPurchase(user, item); + }); + assertEquals(true, secondStarted.await(5, TimeUnit.SECONDS)); + Thread.sleep(50); + assertEquals(1, calls.get()); + releaseFirst.countDown(); + assertEquals(VoteShopPurchaseResult.SUCCESS, one.get(5, TimeUnit.SECONDS)); + assertEquals(VoteShopPurchaseResult.NOT_ENOUGH_POINTS, two.get(5, TimeUnit.SECONDS)); + verify(user, times(2)).removePoints(10, true); + } finally { + releaseFirst.countDown(); + executor.shutdownNow(); + } + } } From ebfbc550360b2d5c90612f35cabd8d22fb30df92 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:56:37 -0600 Subject: [PATCH 10/74] Make concurrent debit regression deterministic --- VotingPlugin/pom.xml | 7 ++++++- .../service/VoteShopPurchaseServiceTest.java | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 465e2f743..8eacbf62c 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -68,6 +68,11 @@ + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + org.apache.maven.plugins maven-compiler-plugin @@ -692,4 +697,4 @@ - \ No newline at end of file + diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index adc9e0b7e..d1d160c7c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; @@ -13,6 +14,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; @@ -67,13 +69,19 @@ void concurrentWrappersCannotDebitSamePlayerTogether() throws Exception { try { Future one = executor.submit(() -> first.debitForPurchase(user, item)); assertEquals(true, firstInsideDebit.await(5, TimeUnit.SECONDS)); - CountDownLatch secondStarted = new CountDownLatch(1); + AtomicReference secondThread = new AtomicReference<>(); Future two = executor.submit(() -> { - secondStarted.countDown(); + secondThread.set(Thread.currentThread()); return second.debitForPurchase(user, item); }); - assertEquals(true, secondStarted.await(5, TimeUnit.SECONDS)); - Thread.sleep(50); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + while (System.nanoTime() < deadline + && (secondThread.get() == null || secondThread.get().getState() != Thread.State.BLOCKED) + && !two.isDone()) { + Thread.onSpinWait(); + } + assertTrue(secondThread.get() != null && secondThread.get().getState() == Thread.State.BLOCKED, + "the second debit did not block on the shared purchase lock"); assertEquals(1, calls.get()); releaseFirst.countDown(); assertEquals(VoteShopPurchaseResult.SUCCESS, one.get(5, TimeUnit.SECONDS)); From ca4471d917a2aee6e4e9c67d7897d0a341a130b3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:14:47 -0600 Subject: [PATCH 11/74] fix: make shared vote shop purchases atomic --- .../votingplugin/commands/CommandLoader.java | 135 ++++------ .../commands/gui/player/VoteShop.java | 27 +- .../commands/gui/player/VoteShopConfirm.java | 52 ++-- .../user/SharedMysqlPointMutator.java | 158 ++++++++++++ .../votingplugin/user/VotingPluginUser.java | 130 +++++++--- .../voteshop/VoteShopManager.java | 9 +- .../service/VoteShopPurchaseService.java | 234 ++++++++++++++++- .../user/SharedMysqlPointMutatorTest.java | 130 ++++++++++ .../VotingPluginUserPointSchedulingTest.java | 129 ++++++++++ .../service/VoteShopPurchaseServiceTest.java | 239 ++++++++++++++++++ 10 files changed, 1077 insertions(+), 166 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 4619e2b39..4b666718e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -89,6 +89,9 @@ import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType; import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult; +import com.bencodez.votingplugin.voteshop.shop.VoteShopEntry; +import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; import com.bencodez.votingplugin.votesites.VoteSite; public class CommandLoader { @@ -495,34 +498,49 @@ public void executeAll(CommandSender sender, String[] args) { sender.sendMessage( MessageAPI.colorize("&cGiving " + "all players" + " " + args[3] + " points")); - for (String uuidStr : plugin.getUserManager().getAllUUIDs()) { + java.util.List userIds = new java.util.ArrayList<>(plugin.getUserManager().getAllUUIDs()); + if (userIds.isEmpty()) { + sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); + return; + } + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(userIds.size()); + java.util.concurrent.atomic.AtomicInteger removed = new java.util.concurrent.atomic.AtomicInteger(); + for (String uuidStr : userIds) { UUID uuid = UUID.fromString(uuidStr); VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.removePoints(num); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), - "amount", args[3]); - } + user.removePoints(num, success -> { + if (success) { + removed.incrementAndGet(); + if (user.isOnline()) user.sendMessage( + plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), + "amount", args[3]); + } + if (remaining.decrementAndGet() == 0) { + sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + + removed.get() + "/" + userIds.size() + " players")); + plugin.getPlaceholders().onUpdate(); + } + }); } - sender.sendMessage( - MessageAPI.colorize("&cRemoved " + "all players" + " " + args[3] + " points")); - - plugin.getPlaceholders().onUpdate(); } @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.cache(); - user.removePoints(Integer.parseInt(args[3])); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), - "amount", args[3]); - } - sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + args[1] + ", " - + args[1] + " now has " + user.getPoints() + " points")); - plugin.getPlaceholders().onUpdate(user, false); + user.removePoints(Integer.parseInt(args[3]), removed -> { + if (!removed) { + sender.sendMessage(MessageAPI.colorize("&cUnable to remove " + args[3] + " points from " + + args[1])); + return; + } + if (user.isOnline()) user.sendMessage( + plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), "amount", args[3]); + sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + args[1])); + plugin.getPlaceholders().onUpdate(user, false); + }); } }); @@ -3236,11 +3254,6 @@ public void execute(CommandSender sender, String[] args) { @Override public void execute(CommandSender sender, String[] args) { - if (!plugin.getShopFile().isVoteShopEnabled()) { - sender.sendMessage(MessageAPI.colorize("&cVote shop disabled")); - return; - } - String identifier = args[1]; Set identifiers = plugin.getShopFile().getShopIdentifiers(); if (ArrayUtils.containsIgnoreCase(identifiers, identifier)) { @@ -3250,61 +3263,19 @@ public void execute(CommandSender sender, String[] args) { } } - String perm = plugin.getShopFile().getVoteShopPermission(identifier); - boolean hasPerm = false; - if (perm.isEmpty()) { - hasPerm = true; - } else { - hasPerm = sender.hasPermission(perm); - } - - int limit = plugin.getShopFile().getShopIdentifierLimit(identifier); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(sender.getName()); - boolean limitPass = true; - if (limit > 0) { - - if (user.getVoteShopIdentifierLimit(identifier) >= limit) { - limitPass = false; - } + VoteShopEntry entry = plugin.getVoteShopManager().getMainEntry(identifier); + if (!(entry instanceof VoteShopItem)) { + sendMessage(sender, "&cWrong voteshop item"); + return; } - - if (!plugin.getShopFile().getVoteShopNotBuyable(identifier)) { - if (hasPerm) { - if (plugin.getConfigFile().isExtraVoteShopCheck()) { - user.cache(); - } - int points = plugin.getShopFile().getShopIdentifierCost(identifier); - if (identifier != null) { - - if (limitPass) { - HashMap placeholders = new HashMap<>(); - placeholders.put("identifier", identifier); - placeholders.put("points", "" + points); - placeholders.put("limit", "" + limit); - if (user.removePoints(points, true)) { - - plugin.getRewardHandler().giveReward(user, plugin.getShopFile().getData(), - plugin.getShopFile().getShopIdentifierRewardsPath(identifier), - new RewardOptions().setPlaceholders(placeholders)); - - user.sendMessage(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatShopPurchaseMsg(), placeholders)); - if (limit > 0) { - user.setVoteShopIdentifierLimit(identifier, - user.getVoteShopIdentifierLimit(identifier) + 1); - } - } else { - user.sendMessage(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatShopFailedMsg(), placeholders)); - } - } else { - user.sendMessage(plugin.getShopFile().getVoteShopLimitReached()); - } - } - + VoteShopItem item = (VoteShopItem) entry; + plugin.getVoteShopManager().purchase((Player) sender, user, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage((Player) sender, user, + item, result); } - } + }); } else { sendMessage(sender, "&cWrong voteshop item"); } @@ -3739,9 +3710,8 @@ public void execute(CommandSender sender, String[] args) { } int pointsToGive = Integer.parseInt(args[2]); if (pointsToGive > 0) { - if (cPlayer.getPoints() >= pointsToGive) { - user.addPoints(pointsToGive); - cPlayer.removePoints(pointsToGive); + cPlayer.transferPoints(user, pointsToGive, transferred -> { + if (transferred) { HashMap placeholders = new HashMap<>(); placeholders.put("transfer", "" + pointsToGive); placeholders.put("touser", "" + user.getPlayerName()); @@ -3754,10 +3724,11 @@ public void execute(CommandSender sender, String[] args) { user.sendMessage(PlaceholderUtils.replacePlaceHolder( plugin.getConfigFile().getFormatCommandsVoteGivePointsTransferTo(), placeholders)); - } else { - sendMessage(sender, plugin.getConfigFile() - .getFormatCommandsVoteGivePointsNotEnoughPoints()); - } + } else { + sendMessage(sender, plugin.getConfigFile() + .getFormatCommandsVoteGivePointsNotEnoughPoints()); + } + }); } else { sendMessage(sender, plugin.getConfigFile() .getFormatCommandsVoteGivePointsNumberLowerThanZero()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java index 0b3bb2400..7517425b4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java @@ -224,20 +224,21 @@ public void onClick(ClickEvent clickEvent) { */ protected void handlePurchase(Player player, VotingPluginUser currentUser, VoteShopItem item, VoteShopCategory category) { - VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, currentUser, item); - if (result != VoteShopPurchaseResult.SUCCESS) { - plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result); - return; - } + plugin.getVoteShopManager().purchase(player, currentUser, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, currentUser, item, result); + return; + } - plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier()); - if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) { - if (category != null) { - new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST); - } else { - plugin.getCommandLoader().processSlotClick(player, currentUser, "shop"); + plugin.getCommandLoader().processSlotClick(player, currentUser, item.getIdentifier()); + if (plugin.getVoteShopManager().getDefinition().isReopenGuiOnPurchase()) { + if (category != null) { + new VoteShopCategoryMenu(plugin, player, currentUser, category).open(GUIMethod.CHEST); + } else { + plugin.getCommandLoader().processSlotClick(player, currentUser, "shop"); + } } - } + }); } /** @@ -257,4 +258,4 @@ protected VotingPluginUser getUser(Player player) { public void open() { open(GUIMethod.CHEST); } -} \ No newline at end of file +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java index 56dd5d0ac..1784eba26 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java @@ -72,19 +72,20 @@ public void onChest(final Player player) { @Override public void onClick(ClickEvent event) { user.cache(); - VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(player, user, item); - if (result != VoteShopPurchaseResult.SUCCESS) { - plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result); - returnToPrevious(event.getPlayer()); - return; - } + plugin.getVoteShopManager().purchase(player, user, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result); + returnToPrevious(event.getPlayer()); + return; + } - plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier()); - if (item.isCloseGUI()) { - event.closeInventory(); - } else { - returnToPrevious(event.getPlayer()); - } + plugin.getCommandLoader().processSlotClick(player, user, item.getIdentifier()); + if (item.isCloseGUI()) { + event.closeInventory(); + } else { + returnToPrevious(event.getPlayer()); + } + }); } }); inv.addButton(new BInventoryButton(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem())) { @@ -118,18 +119,19 @@ public void onDialog(Player player) { } user.cache(); - VoteShopPurchaseResult result = plugin.getVoteShopManager().purchase(clicked, user, item); - if (result != VoteShopPurchaseResult.SUCCESS) { - plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item, - result); - returnToPrevious(clicked); - return; - } - - plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier()); - if (!item.isCloseGUI()) { - returnToPrevious(clicked); - } + plugin.getVoteShopManager().purchase(clicked, user, item, result -> { + if (result != VoteShopPurchaseResult.SUCCESS) { + plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item, + result); + returnToPrevious(clicked); + return; + } + + plugin.getCommandLoader().processSlotClick(clicked, user, item.getIdentifier()); + if (!item.isCloseGUI()) { + returnToPrevious(clicked); + } + }); }).onNo(payload -> { Player clicked = player.getServer().getPlayer(payload.owner()); if (clicked != null) { @@ -155,4 +157,4 @@ protected void returnToPrevious(Player player) { public void open() { open(GUIMethod.CHEST); } -} \ No newline at end of file +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java new file mode 100644 index 000000000..e3f134f2d --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -0,0 +1,158 @@ +package com.bencodez.votingplugin.user; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.UUID; + +import org.bukkit.Bukkit; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.mysql.DbType; +import com.bencodez.votingplugin.VotingPluginMain; + +/** Performs point writes that must remain atomic across shared MySQL servers. */ +final class SharedMysqlPointMutator { + private final VotingPluginMain plugin; + + SharedMysqlPointMutator(VotingPluginMain plugin) { + this.plugin = plugin; + } + + boolean applies() { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && !plugin.getBungeeSettings().isPerServerPoints(); + } + + void add(VotingPluginUser user, int amount, boolean async) { + run(() -> update(user, amount, false), async); + } + + void set(VotingPluginUser user, int value, boolean async) { + run(() -> setAbsolute(user, value), async); + } + + void cap(VotingPluginUser user, int maximum, boolean async) { + run(() -> capAt(user, maximum), async); + } + + boolean remove(VotingPluginUser user, int amount) { + return update(user, -amount, true); + } + + boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { + drainCache(source); + drainCache(target); + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); + String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + String debit = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(sourcePoints) + " = " + + table.qi(sourcePoints) + " - ? WHERE " + uuidMatch + " AND " + table.qi(sourcePoints) + " >= ?"; + String credit = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(targetPoints) + " = " + + table.qi(targetPoints) + " + ? WHERE " + uuidMatch; + try (Connection connection = table.getMysql().getConnectionManager().getConnection()) { + connection.setAutoCommit(false); + try (PreparedStatement debitStatement = connection.prepareStatement(debit); + PreparedStatement creditStatement = connection.prepareStatement(credit)) { + debitStatement.setInt(1, amount); + debitStatement.setString(2, source.getUUID()); + debitStatement.setInt(3, amount); + if (debitStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + creditStatement.setInt(1, amount); + creditStatement.setString(2, target.getUUID()); + if (creditStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + connection.commit(); + return true; + } catch (SQLException failure) { + connection.rollback(); + throw failure; + } + } catch (SQLException failure) { + logFailure(failure); + return false; + } + } + + private void run(Runnable operation, boolean async) { + if (async || (Bukkit.getServer() != null && Bukkit.isPrimaryThread())) { + plugin.getTimer().execute(operation); + } else { + operation.run(); + } + } + + private boolean update(VotingPluginUser user, int delta, boolean requireNonnegative) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ") + .append(table.qi(points)).append(" = ").append(table.qi(points)).append(" + ? WHERE ") + .append(table.qi("uuid")).append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + if (requireNonnegative) { + sql.append(" AND ").append(table.qi(points)).append(" >= ?"); + } + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + statement.setInt(1, delta); + statement.setString(2, user.getUUID()); + if (requireNonnegative) statement.setInt(3, -delta); + return statement.executeUpdate() == 1; + } catch (SQLException failure) { + logFailure(failure); + return false; + } + } + + private void setAbsolute(VotingPluginUser user, int value) { + drainCache(user); + MySQL table = plugin.getMysql(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(user.getPointsPath()) + + " = ? WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, value); + statement.setString(2, user.getUUID()); + statement.executeUpdate(); + } catch (SQLException failure) { + logFailure(failure); + } + } + + private void capAt(VotingPluginUser user, int maximum) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" + + table.qi(points) + ", ?) WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, maximum); + statement.setString(2, user.getUUID()); + statement.executeUpdate(); + } catch (SQLException failure) { + logFailure(failure); + } + } + + private void drainCache(VotingPluginUser user) { + if (user.isCached()) { + user.getCache().dump(); + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } + } + + private void logFailure(SQLException failure) { + plugin.getLogger().severe("Unable to update shared MySQL vote points: " + failure.getClass().getSimpleName()); + plugin.debug(failure); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index c7907b0f4..72fd7b751 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -14,7 +14,8 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; -import java.util.UUID; +import java.util.UUID; +import java.util.function.Consumer; import java.util.stream.Collectors; import org.bukkit.Bukkit; @@ -182,10 +183,13 @@ public void addPoints() { if (points != 0) { addPoints(points); } - if (plugin.getConfigFile().getLimitVotePoints() > 0) { - if (getPoints() > plugin.getConfigFile().getLimitVotePoints()) { - setPoints(plugin.getConfigFile().getLimitVotePoints()); - } + if (plugin.getConfigFile().getLimitVotePoints() > 0) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.cap(this, plugin.getConfigFile().getLimitVotePoints(), false); + } else if (getPoints() > plugin.getConfigFile().getLimitVotePoints()) { + setPoints(plugin.getConfigFile().getLimitVotePoints()); + } } } @@ -206,15 +210,20 @@ public int addPoints(int value) { * @param async whether to add the points asynchronously * @return the current total points */ - public synchronized int addPoints(int value, boolean async) { + public synchronized int addPoints(int value, boolean async) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { return getPoints(); } - int newTotal = getPoints() + event.getPoints(); - setPoints(newTotal, async); + int newTotal = getPoints() + event.getPoints(); + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.add(this, event.getPoints(), async); + } else { + setPoints(newTotal, async); + } return newTotal; } @@ -1300,28 +1309,71 @@ public void playerVote(VoteSite voteSite, boolean online, boolean bungee) { * @param points the number of points to remove * @return true if the points were removed, false otherwise */ - public boolean removePoints(int points) { - if (getPoints() >= points) { - setPoints(getPoints() - points); - return true; - } - return false; - } - - /** - * Removes points from the user asynchronously. - * - * @param points the number of points to remove - * @param async whether to remove the points asynchronously - * @return true if the points were removed, false otherwise - */ - public boolean removePoints(int points, boolean async) { - if (getPoints() >= points) { - setPoints(getPoints() - points, async); - return true; - } - return false; - } + public boolean removePoints(int points) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) return sharedPoints.remove(this, points); + if (getPoints() >= points) { + setPoints(getPoints() - points); + return true; + } + return false; + } + + /** + * Removes points from the user asynchronously. + * + * @param points the number of points to remove + * @param async whether to remove the points asynchronously + * @return true if the points were removed, false otherwise + */ + public boolean removePoints(int points, boolean async) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) return sharedPoints.remove(this, points); + if (getPoints() >= points) { + setPoints(getPoints() - points, async); + return true; + } + return false; + } + + /** Removes points without performing shared-database I/O on the caller thread. */ + public void removePoints(int points, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + completion.accept(removePoints(points)); + return; + } + Player player = getPlayer(); + plugin.getTimer().execute(() -> { + boolean removed = sharedPoints.remove(this, points); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(removed), player); + }); + } + + /** + * Atomically transfers points to another user when points are shared through + * MySQL, reporting completion on the Bukkit thread. + * + * @param target recipient + * @param points positive number of points + * @param completion whether the transfer completed + */ + public void transferPoints(VotingPluginUser target, int points, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + Player player = getPlayer(); + plugin.getTimer().execute(() -> { + boolean transferred = sharedPoints.transfer(this, target, points); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), player); + }); + return; + } + boolean transferred = removePoints(points); + if (transferred) { + target.addPoints(points); + } + completion.accept(transferred); + } /** * Resets the last voted time for all vote sites. @@ -1589,8 +1641,13 @@ public void setOfflineVotes(ArrayList offlineVotes) { * * @param value the number of points */ - public void setPoints(int value) { - getUserData().setInt(getPointsPath(), value, false); + public void setPoints(int value) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.set(this, value, false); + } else { + getUserData().setInt(getPointsPath(), value, false); + } } /** @@ -1599,8 +1656,13 @@ public void setPoints(int value) { * @param value the number of points * @param async whether to set the points asynchronously */ - public void setPoints(int value, boolean async) { - getUserData().setInt(getPointsPath(), value, false, async); + public void setPoints(int value, boolean async) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (sharedPoints.applies()) { + sharedPoints.set(this, value, async); + } else { + getUserData().setInt(getPointsPath(), value, false, async); + } } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java index ba591ed3b..db61b82b2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java @@ -1,5 +1,7 @@ package com.bencodez.votingplugin.voteshop; +import java.util.function.Consumer; + import org.bukkit.entity.Player; import com.bencodez.votingplugin.VotingPluginMain; @@ -91,9 +93,10 @@ public VoteShopCategory getCategory(String categoryId) { * @param player the player * @param user the user * @param item the item - * @return the result + * @param completion completion callback on the Bukkit thread */ - public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) { - return purchaseService.purchase(player, user, item); + public void purchase(Player player, VotingPluginUser user, VoteShopItem item, + Consumer completion) { + purchaseService.purchase(player, user, item, completion); } } 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 00eedbbad..e20ce36a8 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,12 +1,28 @@ package com.bencodez.votingplugin.voteshop.service; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; import java.util.HashMap; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; import org.bukkit.Bukkit; 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.change.UserDataChangeInt; +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; @@ -24,6 +40,10 @@ 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; private VoteShopDefinition definition; @@ -49,6 +69,20 @@ 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; + } + 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; + } + + private VoteShopPurchaseResult validateStaticPurchase(Player player, VoteShopItem item) { if (!definition.isEnabled()) { return VoteShopPurchaseResult.SHOP_DISABLED; } @@ -61,12 +95,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; } @@ -78,7 +106,8 @@ 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; @@ -94,6 +123,94 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot if (debit != VoteShopPurchaseResult.SUCCESS) { return debit; } + completePurchase(player, user, item, placeholders); + 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 (!usesSharedMysqlPoints()) { + completion.accept(purchaseLocal(player, user, item)); + return; + } + VoteShopPurchaseResult validation = validateStaticPurchase(player, item); + if (validation != VoteShopPurchaseResult.SUCCESS) { + completion.accept(validation); + return; + } + HashMap placeholders = purchasePlaceholders(item); + plugin.getTimer().execute(() -> { + VoteShopPurchaseResult debit; + synchronized (purchaseLock(user.getUUID())) { + debit = debitSharedMysql(user, item); + } + if (debit != VoteShopPurchaseResult.SUCCESS) { + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit), player); + return; + } + completeSharedMysqlPurchase(player, user, item, placeholders, completion); + }); + } + + private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item, + HashMap placeholders, Consumer completion) { + CountDownLatch completed = new CountDownLatch(1); + AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); + try { + CompletableFuture scheduled = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(player, ignored -> { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; + try { + completePurchase(player, user, item, placeholders); + completion.accept(VoteShopPurchaseResult.SUCCESS); + } finally { + state.set(COMPLETION_FINISHED); + completed.countDown(); + } + }, () -> requestCompensation(state, completed)); + scheduled.whenComplete((result, failure) -> { + if (failure != null || result != EntityTaskResult.SUCCESS) requestCompensation(state, completed); + }); + while (!completed.await(100, TimeUnit.MILLISECONDS)) { + if (!plugin.isEnabled()) requestCompensation(state, completed); + } + if (state.get() == COMPLETION_COMPENSATING) refundSharedMysqlDebit(user, item); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + if (requestCompensation(state, completed)) refundSharedMysqlDebit(user, item); + } catch (RuntimeException schedulingFailure) { + if (requestCompensation(state, completed)) refundSharedMysqlDebit(user, item); + plugin.debug(schedulingFailure); + } + } + + private static boolean requestCompensation(AtomicInteger state, CountDownLatch completed) { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return false; + completed.countDown(); + return true; + } + + 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) { plugin.getLogger().info("VoteShop: " + user.getPlayerName() + "/" + user.getUUID() + " bought " + item.getIdentifier() + " for " + item.getCost()); @@ -110,12 +227,13 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot VoteShopPurchaseEvent purchaseEvent = new VoteShopPurchaseEvent(player.getUniqueId(), player.getName(), user, item.getIdentifier(), item.getCost()); Bukkit.getPluginManager().callEvent(purchaseEvent); - - return VoteShopPurchaseResult.SUCCESS; } 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; } @@ -130,6 +248,100 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item } } + private boolean usesSharedMysqlPoints() { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && !plugin.getBungeeSettings().isPerServerPoints(); + } + + VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item) { + MySQL table = plugin.getMysql(); + String pointsColumn = user.getPointsPath(); + String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + if (user.isCached()) { + // dump() waits for a cache batch that has already left its queue. Removing + // the drained cache also prevents an older absolute write from racing the + // conditional debit on the shared database. + user.getCache().dump(); + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } + 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) < ?"); + } + + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + 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()); + if (statement.executeUpdate() != 1) { + return sharedMysqlFailure(user, item, limitColumn); + } + refreshPurchaseCache(user, pointsColumn, limitColumn); + return VoteShopPurchaseResult.SUCCESS; + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to atomically debit vote shop points: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + } + } + + private void refundSharedMysqlDebit(VotingPluginUser user, VoteShopItem item) { + MySQL table = plugin.getMysql(); + String pointsColumn = user.getPointsPath(); + String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + 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(" = GREATEST(COALESCE(") + .append(table.qi(limitColumn)).append(", 0) - 1, 0)"); + } + sql.append(" WHERE ").append(table.qi("uuid")) + .append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql.toString())) { + statement.setInt(1, item.getCost()); + statement.setString(2, user.getUUID()); + statement.executeUpdate(); + refreshPurchaseCache(user, pointsColumn, limitColumn); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to refund an incomplete 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) { + if (!user.isCached()) return; + user.getCache().addChange(new UserDataChangeInt(pointsColumn, + user.getUserData().getInt(pointsColumn, UserDataFetchMode.NO_CACHE)), false); + if (limitColumn != null) { + user.getCache().addChange(new UserDataChangeInt(limitColumn, + user.getUserData().getInt(limitColumn, UserDataFetchMode.NO_CACHE)), false); + } + } + Object purchaseLock(String uuid) { return PURCHASE_LOCKS[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)]; } @@ -171,6 +383,10 @@ 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.LIMIT_REACHED) { user.sendMessage(definition.getLimitReachedMessage()); return; 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 000000000..f207c43f8 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -0,0 +1,130 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.mockito.Mockito.times; + +import java.sql.Connection; +import java.sql.PreparedStatement; + +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.votingplugin.VotingPluginMain; + +class SharedMysqlPointMutatorTest { + @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 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); + 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.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + 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).add(user, 10, false); + + ArgumentCaptor query = ArgumentCaptor.forClass(String.class); + verify(connection).prepareStatement(query.capture()); + assertTrue(query.getValue().contains("`Points` = `Points` + ?")); + verify(statement).setInt(1, 10); + verify(statement).executeUpdate(); + } + + @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 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/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java new file mode 100644 index 000000000..0f82784c1 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -0,0 +1,129 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.atomic.AtomicReference; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; + +class VotingPluginUserPointSchedulingTest { + @Test + void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { + PointFixture fixture = pointFixture(); + doReturn(0).when(fixture.user).getPoints(); + when(fixture.statement.executeUpdate()).thenReturn(1); + + assertTrue(fixture.user.removePoints(10)); + verify(fixture.user, never()).getPoints(); + verify(fixture.statement).executeUpdate(); + } + + @Test + void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.statement.executeUpdate()).thenReturn(1); + AtomicReference result = new AtomicReference<>(); + + fixture.user.removePoints(10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + persistenceWork.getValue().run(); + + ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); + assertTrue(result.get() == null); + entityWork.getValue().run(); + assertTrue(result.get()); + verify(fixture.sql.getConnectionManager()).getConnection(); + } + + @Test + void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { + PointFixture fixture = pointFixture(); + VotingPluginUser target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + PreparedStatement credit = mock(PreparedStatement.class); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); + when(fixture.statement.executeUpdate()).thenReturn(1); + when(credit.executeUpdate()).thenReturn(1); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(target, 10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); + assertTrue(result.get() == null); + entityWork.getValue().run(); + assertTrue(result.get()); + } + + private static PointFixture pointFixture() throws Exception { + PointFixture fixture = new PointFixture(); + fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.persistence = mock(ScheduledExecutorService.class); + fixture.scheduler = mock(BukkitScheduler.class); + fixture.player = mock(Player.class); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, + org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.connection = mock(Connection.class); + fixture.statement = mock(PreparedStatement.class); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(fixture.plugin.getMysql()).thenReturn(fixture.table); + when(fixture.plugin.getTimer()).thenReturn(fixture.persistence); + when(fixture.plugin.getBukkitScheduler()).thenReturn(fixture.scheduler); + 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.connection); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement); + fixture.user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(fixture.user, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn("Points").when(fixture.user).getPointsPath(); + doReturn(fixture.player).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isCached(); + return fixture; + } + + private static final class PointFixture { + VotingPluginMain plugin; + ScheduledExecutorService persistence; + BukkitScheduler scheduler; + Player player; + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + Connection connection; + PreparedStatement statement; + VotingPluginUser user; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index d1d160c7c..7a108899b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -4,24 +4,263 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.inOrder; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.times; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; +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.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; class VoteShopPurchaseServiceTest { + @Test + void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.FLAT); + when(plugin.getConfigFile().isExtraVoteShopCheck()).thenReturn(true); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getCost()).thenReturn(10); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getPoints()).thenReturn(0); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(mock(org.bukkit.entity.Player.class), user, item, + result::set); + + assertEquals(VoteShopPurchaseResult.NOT_ENOUGH_POINTS, result.get()); + InOrder refreshBeforeValidation = inOrder(user); + refreshBeforeValidation.verify(user).cache(); + refreshBeforeValidation.verify(user).getPoints(); + } + + @Test + void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() 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 debitConnection = mock(Connection.class); + Connection refundConnection = mock(Connection.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement refund = 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(debitConnection, refundConnection); + when(debitConnection.prepareStatement(anyString())).thenReturn(debit); + when(refundConnection.prepareStatement(anyString())).thenReturn(refund); + when(debit.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.isEnabled()).thenReturn(true); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + when(definition.getTitle()).thenReturn("Vote Shop"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + VotingPluginUser user = purchaseUser(); + AtomicInteger completions = new AtomicInteger(); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(entityScheduler.runAtEntityWithFallback(org.mockito.ArgumentMatchers.eq(player), any(), + any(Runnable.class))).thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + new VoteShopPurchaseService(plugin, definition).purchase(player, user, item, + result -> completions.incrementAndGet()); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(work.capture()); + ExecutorService worker = Executors.newSingleThreadExecutor(); + Future purchase = worker.submit(work.getValue()); + @SuppressWarnings("rawtypes") + ArgumentCaptor scheduled = ArgumentCaptor.forClass(java.util.function.Consumer.class); + ArgumentCaptor retirement = ArgumentCaptor.forClass(Runnable.class); + verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback( + org.mockito.ArgumentMatchers.eq(player), scheduled.capture(), retirement.capture()); + purchase.get(5, TimeUnit.SECONDS); + + ArgumentCaptor refundSql = ArgumentCaptor.forClass(String.class); + verify(refundConnection).prepareStatement(refundSql.capture()); + assertTrue(refundSql.getValue().contains("`Points` = `Points` + ?")); + verify(refund).setInt(1, 10); + verify(refund, times(1)).executeUpdate(); + verify(entityScheduler).runAtEntityWithFallback( + org.mockito.ArgumentMatchers.eq(player), any(), any(Runnable.class)); + scheduled.getValue().accept(null); + assertEquals(0, completions.get(), "a compensated purchase must not complete its reward later"); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + worker.shutdownNow(); + } + + @Test + void disabledShopResultStillSendsFeedback() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + + new VoteShopPurchaseService(plugin, definition).sendFailureMessage(player, mock(VotingPluginUser.class), null, + VoteShopPurchaseResult.SHOP_DISABLED); + + verify(player).sendMessage(anyString()); + } + + @Test + void sharedMysqlDebitWaitsForAndRemovesExistingCache() 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 = sharedMysqlPlugin(table); + VotingPluginUser user = purchaseUser(); + UserDataCache cache = mock(UserDataCache.class); + when(user.isCached()).thenReturn(true, false); + when(user.getCache()).thenReturn(cache); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(plugin, null).debitSharedMysql(user, item)); + + verify(cache).dump(); + verify(plugin.getUserManager().getDataManager()).removeCache( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), null); + } + + @Test + void sharedMysqlPurchaseQueuesDatabaseWorkOffCallingThread() 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); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(table.getMysql()).thenReturn(sql); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + when(definition.getTitle()).thenReturn("Vote Shop"); + VotingPluginUser user = purchaseUser(); + when(user.getPoints()).thenReturn(0); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(1); + when(user.getVoteShopIdentifierLimit(anyString())).thenReturn(1); + + new VoteShopPurchaseService(plugin, definition).purchase(mock(org.bukkit.entity.Player.class), user, item, + result -> { }); + + verify(persistenceExecutor).execute(any(Runnable.class)); + verify(sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void sharedMysqlConditionAllowsOnlyOneBackendDebit() throws Exception { + AtomicInteger sharedBalance = new AtomicInteger(10); + CountDownLatch bothBackendsInsideUpdate = new CountDownLatch(2); + 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); + + Connection firstConnection = mock(Connection.class); + Connection secondConnection = mock(Connection.class); + PreparedStatement firstStatement = conditionalDebitStatement(sharedBalance, bothBackendsInsideUpdate); + PreparedStatement secondStatement = conditionalDebitStatement(sharedBalance, bothBackendsInsideUpdate); + when(firstConnection.prepareStatement(anyString())).thenReturn(firstStatement); + when(secondConnection.prepareStatement(anyString())).thenReturn(secondStatement); + when(sql.getConnectionManager().getConnection()).thenReturn(firstConnection, secondConnection); + + VoteShopPurchaseService first = new VoteShopPurchaseService(sharedMysqlPlugin(table), null); + VoteShopPurchaseService second = new VoteShopPurchaseService(sharedMysqlPlugin(table), null); + VotingPluginUser firstUser = purchaseUser(); + VotingPluginUser secondUser = purchaseUser(); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstResult = executor.submit(() -> first.debitSharedMysql(firstUser, item)); + Future secondResult = executor.submit(() -> second.debitSharedMysql(secondUser, item)); + VoteShopPurchaseResult one = firstResult.get(5, TimeUnit.SECONDS); + VoteShopPurchaseResult two = secondResult.get(5, TimeUnit.SECONDS); + assertTrue((one == VoteShopPurchaseResult.SUCCESS && two == VoteShopPurchaseResult.NOT_ENOUGH_POINTS) + || (two == VoteShopPurchaseResult.SUCCESS && one == VoteShopPurchaseResult.NOT_ENOUGH_POINTS)); + assertEquals(0, sharedBalance.get()); + } finally { + executor.shutdownNow(); + } + } + + private static PreparedStatement conditionalDebitStatement(AtomicInteger balance, CountDownLatch entered) + throws Exception { + PreparedStatement statement = mock(PreparedStatement.class); + when(statement.executeUpdate()).thenAnswer(invocation -> { + entered.countDown(); + entered.await(5, TimeUnit.SECONDS); + return balance.compareAndSet(10, 0) ? 1 : 0; + }); + return statement; + } + + private static VotingPluginMain sharedMysqlPlugin(MySQL table) { + 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); + return plugin; + } + + private static VotingPluginUser purchaseUser() { + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("Points"); + return user; + } @Test void wrappersForSamePlayerShareLockAcrossServiceReloads() { From 38ae0d6fd49bfbf7ea5c4f37bd01b8dbd5eaf02d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 22:50:17 -0600 Subject: [PATCH 12/74] Preserve synchronous shared point updates --- .../bencodez/votingplugin/user/SharedMysqlPointMutator.java | 4 +--- .../votingplugin/user/SharedMysqlPointMutatorTest.java | 6 ++++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index e3f134f2d..89691fab6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -5,8 +5,6 @@ import java.sql.SQLException; import java.util.UUID; -import org.bukkit.Bukkit; - import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.sql.mysql.DbType; @@ -82,7 +80,7 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { } private void run(Runnable operation, boolean async) { - if (async || (Bukkit.getServer() != null && Bukkit.isPrimaryThread())) { + if (async) { plugin.getTimer().execute(operation); } else { operation.run(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index f207c43f8..afb5c816a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -7,9 +7,12 @@ 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.ArgumentMatchers.any; import java.sql.Connection; import java.sql.PreparedStatement; +import java.util.concurrent.ScheduledExecutorService; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -58,6 +61,8 @@ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exce 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"); @@ -69,6 +74,7 @@ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exce assertTrue(query.getValue().contains("`Points` = `Points` + ?")); verify(statement).setInt(1, 10); verify(statement).executeUpdate(); + verify(persistence, never()).execute(any(Runnable.class)); } @Test From e07be214032d2810cbc6008c3fcbad7158cbd919 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 7 Sep 2026 23:20:33 -0600 Subject: [PATCH 13/74] Preserve purchase and transfer semantics --- .../user/SharedMysqlPointMutator.java | 10 ++- .../votingplugin/user/VotingPluginUser.java | 12 +++- .../service/VoteShopPurchaseService.java | 19 +++-- .../VotingPluginUserPointSchedulingTest.java | 72 ++++++++++++++++++- .../service/VoteShopPurchaseServiceTest.java | 69 ++++++++++++++++++ 5 files changed, 171 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 89691fab6..8e33a05a6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -40,6 +40,10 @@ boolean remove(VotingPluginUser user, int amount) { } boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { + return transfer(source, target, amount, amount); + } + + boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, int creditAmount) { drainCache(source); drainCache(target); MySQL table = plugin.getMysql(); @@ -54,14 +58,14 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { connection.setAutoCommit(false); try (PreparedStatement debitStatement = connection.prepareStatement(debit); PreparedStatement creditStatement = connection.prepareStatement(credit)) { - debitStatement.setInt(1, amount); + debitStatement.setInt(1, debitAmount); debitStatement.setString(2, source.getUUID()); - debitStatement.setInt(3, amount); + debitStatement.setInt(3, debitAmount); if (debitStatement.executeUpdate() != 1) { connection.rollback(); return false; } - creditStatement.setInt(1, amount); + creditStatement.setInt(1, creditAmount); creditStatement.setString(2, target.getUUID()); if (creditStatement.executeUpdate() != 1) { connection.rollback(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 72fd7b751..8068ab4d0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1361,9 +1361,19 @@ public void removePoints(int points, Consumer completion) { public void transferPoints(VotingPluginUser target, int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.applies()) { + // Preserve the established recipient hook before the shared transaction is + // queued. The event may cancel or adjust the amount that is credited, while + // the source debit remains the requested transfer amount as in the local path. + PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points); + Bukkit.getPluginManager().callEvent(receiveEvent); + if (receiveEvent.isCancelled()) { + completion.accept(false); + return; + } + int receivedPoints = receiveEvent.getPoints(); Player player = getPlayer(); plugin.getTimer().execute(() -> { - boolean transferred = sharedPoints.transfer(this, target, points); + boolean transferred = sharedPoints.transfer(this, target, points, receivedPoints); plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), player); }); return; 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 e20ce36a8..2849d381d 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 @@ -12,6 +12,7 @@ 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; @@ -113,6 +114,7 @@ private VoteShopPurchaseResult purchaseLocal(Player player, VotingPluginUser use return validation; } + FileConfiguration shopData = plugin.getShopFile().getData(); HashMap placeholders = new HashMap(); placeholders.put("identifier", item.getIdentifierName()); placeholders.put("points", String.valueOf(item.getCost())); @@ -123,7 +125,7 @@ private VoteShopPurchaseResult purchaseLocal(Player player, VotingPluginUser use if (debit != VoteShopPurchaseResult.SUCCESS) { return debit; } - completePurchase(player, user, item, placeholders); + completePurchase(player, user, item, placeholders, shopData); return VoteShopPurchaseResult.SUCCESS; } @@ -148,6 +150,10 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, 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); plugin.getTimer().execute(() -> { VoteShopPurchaseResult debit; @@ -158,12 +164,13 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit), player); return; } - completeSharedMysqlPurchase(player, user, item, placeholders, completion); + completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion); }); } private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item, - HashMap placeholders, Consumer completion) { + HashMap placeholders, FileConfiguration shopData, + Consumer completion) { CountDownLatch completed = new CountDownLatch(1); AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); try { @@ -171,7 +178,7 @@ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, V .runAtEntityWithFallback(player, ignored -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; try { - completePurchase(player, user, item, placeholders); + completePurchase(player, user, item, placeholders, shopData); completion.accept(VoteShopPurchaseResult.SUCCESS); } finally { state.set(COMPLETION_FINISHED); @@ -210,12 +217,12 @@ private HashMap purchasePlaceholders(VoteShopItem item) { } private void completePurchase(Player player, VotingPluginUser user, VoteShopItem item, - HashMap placeholders) { + 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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 0f82784c1..40c0822ce 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -6,10 +6,12 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doAnswer; import java.lang.reflect.Field; import java.sql.Connection; @@ -18,13 +20,17 @@ 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.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; class VotingPluginUserPointSchedulingTest { @Test @@ -71,7 +77,11 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { when(credit.executeUpdate()).thenReturn(1); AtomicReference result = new AtomicReference<>(); - fixture.user.transferPoints(target, 10, result::set); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.transferPoints(target, 10, result::set); + } ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); @@ -83,6 +93,66 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { assertTrue(result.get()); } + @Test + void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exception { + PointFixture fixture = pointFixture(); + VotingPluginUser target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + PreparedStatement credit = mock(PreparedStatement.class); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); + when(fixture.statement.executeUpdate()).thenReturn(1); + when(credit.executeUpdate()).thenReturn(1); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(target, 10, result::set); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); + entityWork.getValue().run(); + } + + assertTrue(result.get()); + verify(credit).setInt(1, 4); + } + + @Test + void cancelledSharedTransferDoesNotDebitOrQueueDatabaseWork() { + PointFixture fixture; + try { + fixture = pointFixture(); + } catch (Exception failure) { + throw new AssertionError(failure); + } + VotingPluginUser target = mock(VotingPluginUser.class); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setCancelled(true); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(target, 10, result::set); + } + + assertTrue(Boolean.FALSE.equals(result.get())); + verify(fixture.persistence, never()).execute(any(Runnable.class)); + } + private static PointFixture pointFixture() throws Exception { PointFixture fixture = new PointFixture(); fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 7a108899b..c3d92639c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -7,6 +7,7 @@ import static org.mockito.Mockito.inOrder; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; import static org.mockito.Mockito.verify; @@ -24,6 +25,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import org.bukkit.configuration.file.FileConfiguration; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; @@ -195,6 +197,73 @@ void sharedMysqlPurchaseQueuesDatabaseWorkOffCallingThread() throws Exception { verify(sql.getConnectionManager(), never()).getConnection(); } + @Test + void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() 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 = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + when(definition.getTitle()).thenReturn("Vote Shop"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + when(item.getIdentifier()).thenReturn("old-item"); + when(item.getIdentifierName()).thenReturn("Old item"); + when(item.getRewardsPath()).thenReturn("Shop.old-item.Rewards"); + when(item.getPurchaseMessage()).thenReturn(""); + VotingPluginUser user = purchaseUser(); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + FileConfiguration oldShopData = mock(FileConfiguration.class); + FileConfiguration reloadedShopData = mock(FileConfiguration.class); + when(plugin.getShopFile().getData()).thenReturn(oldShopData, reloadedShopData); + org.bukkit.plugin.PluginManager pluginManager = mock(org.bukkit.plugin.PluginManager.class); + AtomicReference result = new AtomicReference<>(); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(pluginManager); + new VoteShopPurchaseService(plugin, definition).purchase(player, user, item, result::set); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(work.capture()); + // Simulate a reload replacing ShopFile's active configuration before the + // delayed database/entity work gets to the reward executor. + ExecutorService worker = Executors.newSingleThreadExecutor(); + Future purchase = worker.submit(work.getValue()); + @SuppressWarnings("rawtypes") + ArgumentCaptor entityCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback(any(), + entityCallback.capture(), any(Runnable.class)); + entityCallback.getValue().accept(null); + purchase.get(5, TimeUnit.SECONDS); + worker.shutdownNow(); + } + + assertEquals(VoteShopPurchaseResult.SUCCESS, result.get()); + verify(plugin.getRewardHandler()).giveReward(eq(user), eq(oldShopData), eq("Shop.old-item.Rewards"), any()); + verify(plugin.getRewardHandler(), never()).giveReward(eq(user), eq(reloadedShopData), anyString(), any()); + } + @Test void sharedMysqlConditionAllowsOnlyOneBackendDebit() throws Exception { AtomicInteger sharedBalance = new AtomicInteger(10); From c5ddb3c541cca7e1857d01c94bedf7b18d9b5ddc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 01:06:49 -0600 Subject: [PATCH 14/74] Reserve shared transfer points before hooks --- .../user/SharedMysqlPointMutator.java | 31 +++++++ .../votingplugin/user/VotingPluginUser.java | 16 ++-- .../user/SharedMysqlPointMutatorTest.java | 2 +- .../VotingPluginUserPointSchedulingTest.java | 86 ++++++++++++++++--- 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 8e33a05a6..36c6b4ea2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -4,6 +4,7 @@ import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.UUID; +import java.util.function.IntFunction; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; @@ -44,6 +45,18 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { } boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, int creditAmount) { + return transfer(source, target, debitAmount, ignored -> creditAmount); + } + + /** + * Transfers points while allowing the recipient hook to approve or adjust the + * credit after the conditional debit has succeeded. The approval callback is + * invoked on the persistence worker after the conditional debit. The receive + * event is explicitly asynchronous, so no server-thread rendezvous is needed + * while the transaction is open and cancellation can still roll back atomically. + */ + boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, + IntFunction creditAmountProvider) { drainCache(source); drainCache(target); MySQL table = plugin.getMysql(); @@ -65,6 +78,18 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou connection.rollback(); return false; } + Integer creditAmount; + try { + creditAmount = creditAmountProvider.apply(debitAmount); + } catch (RuntimeException failure) { + connection.rollback(); + logApprovalFailure(failure); + return false; + } + if (creditAmount == null) { + connection.rollback(); + return false; + } creditStatement.setInt(1, creditAmount); creditStatement.setString(2, target.getUUID()); if (creditStatement.executeUpdate() != 1) { @@ -157,4 +182,10 @@ private void logFailure(SQLException failure) { plugin.getLogger().severe("Unable to update shared MySQL vote points: " + failure.getClass().getSimpleName()); plugin.debug(failure); } + + private void logApprovalFailure(RuntimeException failure) { + plugin.getLogger().severe("Unable to approve shared MySQL point transfer on the persistence worker: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 8068ab4d0..c2cc59f37 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1361,19 +1361,13 @@ public void removePoints(int points, Consumer completion) { public void transferPoints(VotingPluginUser target, int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.applies()) { - // Preserve the established recipient hook before the shared transaction is - // queued. The event may cancel or adjust the amount that is credited, while - // the source debit remains the requested transfer amount as in the local path. - PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points); - Bukkit.getPluginManager().callEvent(receiveEvent); - if (receiveEvent.isCancelled()) { - completion.accept(false); - return; - } - int receivedPoints = receiveEvent.getPoints(); Player player = getPlayer(); plugin.getTimer().execute(() -> { - boolean transferred = sharedPoints.transfer(this, target, points, receivedPoints); + boolean transferred = sharedPoints.transfer(this, target, points, ignored -> { + PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points); + Bukkit.getPluginManager().callEvent(receiveEvent); + return receiveEvent.isCancelled() ? null : receiveEvent.getPoints(); + }); plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), player); }); return; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index afb5c816a..e519dc1dd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -13,7 +13,6 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.util.concurrent.ScheduledExecutorService; - import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -133,4 +132,5 @@ void transferCreditsOnlyAfterConditionalDebitSucceeds() throws Exception { verify(connection).commit(); verify(connection, times(0)).rollback(); } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 40c0822ce..545f481d9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -1,12 +1,15 @@ 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.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.CALLS_REAL_METHODS; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.verify; @@ -25,6 +28,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; +import org.mockito.InOrder; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; @@ -76,20 +80,27 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { when(fixture.statement.executeUpdate()).thenReturn(1); when(credit.executeUpdate()).thenReturn(1); AtomicReference result = new AtomicReference<>(); + AtomicReference eventThread = new AtomicReference<>(); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { PluginManager pluginManager = mock(PluginManager.class); bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + eventThread.set(Thread.currentThread()); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); fixture.user.transferPoints(target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + Thread persistenceThread = Thread.currentThread(); + persistenceWork.getValue().run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + assertTrue(result.get() == null); + completion.getValue().run(); + assertEquals(persistenceThread, eventThread.get(), + "the asynchronous receive hook must not rendezvous with the server thread inside the transaction"); } - - ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence).execute(persistenceWork.capture()); - persistenceWork.getValue().run(); - ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); - assertTrue(result.get() == null); - entityWork.getValue().run(); assertTrue(result.get()); } @@ -115,20 +126,54 @@ void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exc }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); fixture.user.transferPoints(target, 10, result::set); + 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(); + InOrder transferOrder = inOrder(fixture.statement, pluginManager, credit); + transferOrder.verify(fixture.statement).executeUpdate(); + transferOrder.verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + transferOrder.verify(credit).executeUpdate(); + } + + assertTrue(result.get()); + verify(credit).setInt(1, 4); + } + + @Test + void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws Exception { + PointFixture fixture = pointFixture(); + VotingPluginUser target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + PreparedStatement credit = mock(PreparedStatement.class); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); + when(fixture.statement.executeUpdate()).thenReturn(0); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.transferPoints(target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); ArgumentCaptor entityWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), entityWork.capture(), eq(fixture.player)); entityWork.getValue().run(); + + verify(pluginManager, never()).callEvent(any(PlayerReceivePointsEvent.class)); + verify(credit, never()).executeUpdate(); } - assertTrue(result.get()); - verify(credit).setInt(1, 4); + assertFalse(result.get()); } @Test - void cancelledSharedTransferDoesNotDebitOrQueueDatabaseWork() { + void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { PointFixture fixture; try { fixture = pointFixture(); @@ -136,6 +181,11 @@ void cancelledSharedTransferDoesNotDebitOrQueueDatabaseWork() { throw new AssertionError(failure); } VotingPluginUser target = mock(VotingPluginUser.class); + when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(target.getPointsPath()).thenReturn("Points"); + PreparedStatement credit = mock(PreparedStatement.class); + when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); + when(fixture.statement.executeUpdate()).thenReturn(1); AtomicReference result = new AtomicReference<>(); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { @@ -147,10 +197,21 @@ void cancelledSharedTransferDoesNotDebitOrQueueDatabaseWork() { return null; }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); fixture.user.transferPoints(target, 10, result::set); + + 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(); + + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + verify(fixture.connection).rollback(); + verify(fixture.connection, never()).commit(); + verify(credit, never()).executeUpdate(); } assertTrue(Boolean.FALSE.equals(result.get())); - verify(fixture.persistence, never()).execute(any(Runnable.class)); } private static PointFixture pointFixture() throws Exception { @@ -196,4 +257,5 @@ private static final class PointFixture { PreparedStatement statement; VotingPluginUser user; } + } From ca387d7267f22c4a5df3561201296c2b0f58303c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:00:00 -0600 Subject: [PATCH 15/74] Release shared transfer hooks before callbacks --- .../user/SharedMysqlPointMutator.java | 88 ++- .../user/SharedPointTransferJournal.java | 590 ++++++++++++++++++ .../user/SharedPointTransferJournalTest.java | 398 ++++++++++++ .../VotingPluginUserPointSchedulingTest.java | 336 ++++++++-- .../service/VoteShopPurchaseServiceTest.java | 8 +- 5 files changed, 1350 insertions(+), 70 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 36c6b4ea2..e91558eba 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -45,15 +45,15 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { } boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, int creditAmount) { - return transfer(source, target, debitAmount, ignored -> creditAmount); + return transferAtomically(source, target, debitAmount, creditAmount); } /** * Transfers points while allowing the recipient hook to approve or adjust the - * credit after the conditional debit has succeeded. The approval callback is - * invoked on the persistence worker after the conditional debit. The receive - * event is explicitly asynchronous, so no server-thread rendezvous is needed - * while the transaction is open and cancellation can still roll back atomically. + * credit after the conditional debit has succeeded. The reservation transaction + * is committed and its connection is closed before the callback is invoked, so + * arbitrary listeners may safely read from the database. A durable journal then + * makes the refund/credit settlement idempotent. */ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, IntFunction creditAmountProvider) { @@ -62,6 +62,72 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou MySQL table = plugin.getMysql(); String sourcePoints = source.getPointsPath(); String targetPoints = target.getPointsPath(); + String transferId = UUID.randomUUID().toString(); + String owner = UUID.randomUUID().toString(); + SharedPointTransferJournal journal = null; + try { + journal = SharedPointTransferJournal.forTable(table); + journal.recoverAndCleanup(System.currentTimeMillis()); + try { + if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, + System.currentTimeMillis())) return false; + } catch (SQLException failure) { + // If reservation commit acknowledgement was lost, release the source + // only when the journal still proves the hook never started. + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + throw failure; + } + SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, + System.currentTimeMillis()); + if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + return false; + } + if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { + logIndeterminateClaim(transferId); + return true; + } + Integer creditAmount; + try { + creditAmount = creditAmountProvider.apply(debitAmount); + } catch (RuntimeException failure) { + SharedPointTransferJournal.SettlementOutcome outcome = journal.settleWithConfirmation(transferId, owner, + source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, null); + logApprovalFailure(failure); + return isAcceptedSettlement(outcome); + } + SharedPointTransferJournal.SettlementOutcome outcome = journal.settleWithConfirmation(transferId, owner, + source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, creditAmount); + return isAcceptedSettlement(outcome); + } catch (SQLException failure) { + logFailure(failure); + return false; + } + } + + private boolean isAcceptedSettlement(SharedPointTransferJournal.SettlementOutcome outcome) { + if (outcome == SharedPointTransferJournal.SettlementOutcome.INDETERMINATE) { + // The callback has already run. Reporting a retryable failure could create + // a second transfer after an unconfirmed credit, so retain the journal row + // for explicit reconciliation and suppress a new debit attempt. + plugin.getLogger().severe("Shared MySQL point transfer outcome is indeterminate; retaining journal entry for reconciliation"); + return true; + } + return outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED; + } + + private void logIndeterminateClaim(String transferId) { + plugin.getLogger().severe("Shared MySQL point transfer " + transferId + + " has indeterminate claim state (RESERVED or HOOK_STARTED); retaining it for explicit reconciliation"); + } + + private boolean transferAtomically(VotingPluginUser source, VotingPluginUser target, + int debitAmount, int creditAmount) { + drainCache(source); + drainCache(target); + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); String debit = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(sourcePoints) + " = " + table.qi(sourcePoints) + " - ? WHERE " + uuidMatch + " AND " + table.qi(sourcePoints) + " >= ?"; @@ -78,18 +144,6 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou connection.rollback(); return false; } - Integer creditAmount; - try { - creditAmount = creditAmountProvider.apply(debitAmount); - } catch (RuntimeException failure) { - connection.rollback(); - logApprovalFailure(failure); - return false; - } - if (creditAmount == null) { - connection.rollback(); - return false; - } creditStatement.setInt(1, creditAmount); creditStatement.setString(2, target.getUUID()); if (creditStatement.executeUpdate() != 1) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java new file mode 100644 index 000000000..d6908b0d7 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -0,0 +1,590 @@ +package com.bencodez.votingplugin.user; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +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 state for a shared-MySQL point transfer. + * + *

The journal is deliberately stored in the same database as the user row. + * A reservation and its source debit therefore commit together. The event hook + * is invoked only after that short transaction has released its connection; + * settlement is a second short, idempotent transaction.

+ */ +final class SharedPointTransferJournal { + private static final String RESERVED = "RESERVED"; + private static final String HOOK_STARTED = "HOOK_STARTED"; + private static final String COMPLETED = "COMPLETED"; + private static final String REFUNDED = "REFUNDED"; + static final long RESERVED_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; + + private final MySQL table; + private final String journalTable; + /* + * MySQL does not promise identity-based equals/hashCode. A regular + * WeakHashMap can therefore conflate two live handles that compare equal, + * and a value containing the handle would keep its weak key alive. Keep only + * identity weak references as initialization markers instead. + */ + private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); + private static final Set INITIALIZED = new HashSet<>(); + + SharedPointTransferJournal(MySQL table) throws SQLException { + this(table, true); + } + + private SharedPointTransferJournal(MySQL table, boolean initializeSchema) throws SQLException { + this.table = table; + this.journalTable = table.getTableName() + "_PointTransfers"; + if (initializeSchema) ensureSchema(); + } + + /** Returns a journal handle after ensuring the schema once per live MySQL table handle. */ + static SharedPointTransferJournal forTable(MySQL table) throws SQLException { + synchronized (INITIALIZED) { + expungeInitialized(); + for (IdentityWeakReference marker : INITIALIZED) { + if (marker.get() == table) return new SharedPointTransferJournal(table, false); + } + new SharedPointTransferJournal(table, true); + INITIALIZED.add(new IdentityWeakReference(table, INITIALIZED_QUEUE)); + return new SharedPointTransferJournal(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(); + } + } + + 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(); + } + } + + /** + * Inserts a reservation and conditionally debits the source in one transaction. + * A duplicate transfer id is treated as an idempotent retry of the same + * reservation, which is needed after a commit acknowledgement is lost. + */ + boolean reserve(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints, String targetUuid, + int requestedCreditPoints, long now) throws SQLException { + TransferRow existing = find(transferId); + if (existing != null) { + return existing.matches(sourceUuid, targetUuid, debitPoints, requestedCreditPoints) + && !REFUNDED.equals(existing.state); + } + + String insert = "INSERT INTO " + qiJournal() + " (" + qi("transfer_id") + ", " + qi("source_uuid") + + ", " + qi("source_points_column") + ", " + qi("target_uuid") + ", " + qi("debit_points") + + ", " + qi("requested_credit_points") + ", " + qi("state") + ", " + qi("created_at") + + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + String points = qi(sourcePointsColumn); + String debit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " - ? WHERE " + qi("uuid") + uuidCast() + " AND " + points + " >= ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement debitStatement = connection.prepareStatement(debit)) { + insertStatement.setString(1, transferId); + insertStatement.setString(2, sourceUuid); + insertStatement.setString(3, sourcePointsColumn); + insertStatement.setString(4, targetUuid); + insertStatement.setInt(5, debitPoints); + insertStatement.setInt(6, requestedCreditPoints); + insertStatement.setString(7, RESERVED); + insertStatement.setLong(8, now); + insertStatement.executeUpdate(); + + debitStatement.setInt(1, debitPoints); + debitStatement.setString(2, sourceUuid); + debitStatement.setInt(3, debitPoints); + if (debitStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + return commitAndConfirm(connection, transferId, RESERVED); + } catch (SQLException failure) { + rollback(connection); + if (isDuplicate(failure)) { + closeQuietly(connection); + TransferRow duplicate = find(transferId); + return duplicate != null && duplicate.matches(sourceUuid, targetUuid, debitPoints, + requestedCreditPoints) && !REFUNDED.equals(duplicate.state); + } + throw failure; + } + } + } + + /** + * Claims a reservation before invoking the arbitrary external event hook. + * A lost commit acknowledgement is retried by transfer id: a row already + * owned by this attempt proves that the hook may now run exactly once. + */ + ClaimOutcome claimHookWithConfirmation(String transferId, String owner, long startedAt) { + for (int attempt = 0; attempt < 3; attempt++) { + try { + ClaimOutcome outcome = claimHookOnce(transferId, owner, startedAt); + if (outcome != ClaimOutcome.INDETERMINATE) return outcome; + } catch (SQLException ignored) { + // Re-read the same journal row; do not create a second transfer id. + } + } + return ClaimOutcome.INDETERMINATE; + } + + private ClaimOutcome claimHookOnce(String transferId, String owner, long startedAt) throws SQLException { + String select = "SELECT " + qi("state") + ", " + qi("hook_owner") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("hook_owner") + + " = ?, " + qi("hook_started_at") + " = ? WHERE " + qi("transfer_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + connection.rollback(); + return ClaimOutcome.NOT_CLAIMED; + } + String state = result.getString(1); + String currentOwner = result.getString(2); + if (HOOK_STARTED.equals(state)) { + connection.rollback(); + return owner.equals(currentOwner) ? ClaimOutcome.CLAIMED : ClaimOutcome.INDETERMINATE; + } + if (!RESERVED.equals(state)) { + connection.rollback(); + return ClaimOutcome.NOT_CLAIMED; + } + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, HOOK_STARTED); + updateStatement.setString(2, owner); + updateStatement.setLong(3, startedAt); + updateStatement.setString(4, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return ClaimOutcome.INDETERMINATE; + } + commitAndConfirm(connection, transferId, HOOK_STARTED); + return ClaimOutcome.CLAIMED; + } + } + } + + enum ClaimOutcome { + CLAIMED, + NOT_CLAIMED, + INDETERMINATE + } + + /** + * Safely releases a reservation when the hook was never claimed. A + * {@code HOOK_STARTED} row is deliberately left alone because a listener may + * already be executing and replaying/refunding it automatically is unsafe. + */ + boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints) + throws SQLException { + String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + + " = ? FOR UPDATE"; + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next() || !RESERVED.equals(result.getString(1))) { + connection.rollback(); + return false; + } + } + } + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + if (refundStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + return commitAndConfirm(connection, transferId, REFUNDED); + } + } + + /** + * Retries settlement with the same transfer id when a commit acknowledgement + * or its first confirmation read is lost. A terminal state proves the prior + * attempt's outcome; a still-HOOK_STARTED row safely retries the same point + * update. Exhausted attempts remain explicitly indeterminate rather than + * being exposed as a retryable transfer failure. + */ + SettlementOutcome settleWithConfirmation(String transferId, String owner, String sourceUuid, + String sourcePointsColumn, String targetUuid, String targetPointsColumn, int debitPoints, + Integer adjustedCreditPoints) { + for (int attempt = 0; attempt < 3; attempt++) { + try { + SettlementOutcome outcome = settleOnce(transferId, owner, sourceUuid, sourcePointsColumn, targetUuid, + targetPointsColumn, debitPoints, adjustedCreditPoints); + if (outcome != SettlementOutcome.INDETERMINATE) return outcome; + } catch (SQLException ignored) { + // The next attempt re-reads the durable state using the same id. + } + } + return SettlementOutcome.INDETERMINATE; + } + + /** Retained for direct callers that only need the historic completed/not-completed boolean. */ + boolean settle(String transferId, String owner, String sourceUuid, String sourcePointsColumn, String targetUuid, + String targetPointsColumn, int debitPoints, Integer adjustedCreditPoints) { + return settleWithConfirmation(transferId, owner, sourceUuid, sourcePointsColumn, targetUuid, + targetPointsColumn, debitPoints, adjustedCreditPoints) == SettlementOutcome.COMPLETED; + } + + private SettlementOutcome settleOnce(String transferId, String owner, String sourceUuid, String sourcePointsColumn, + String targetUuid, String targetPointsColumn, int debitPoints, Integer adjustedCreditPoints) throws SQLException { + String select = "SELECT " + qi("state") + ", " + qi("hook_owner") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; + String points = qi(adjustedCreditPoints == null ? sourcePointsColumn : targetPointsColumn); + String credit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String updateJournal = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + + qi("adjusted_credit_points") + " = ? WHERE " + qi("transfer_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + String state; + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + state = result.getString(1); + String currentOwner = result.getString(2); + if (COMPLETED.equals(state)) { + connection.rollback(); + return SettlementOutcome.COMPLETED; + } + if (REFUNDED.equals(state)) { + connection.rollback(); + return SettlementOutcome.REFUNDED; + } + if (!HOOK_STARTED.equals(state) || !owner.equals(currentOwner)) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + } + } + + boolean refund = adjustedCreditPoints == null; + try (PreparedStatement pointStatement = connection.prepareStatement(credit)) { + pointStatement.setInt(1, refund ? debitPoints : adjustedCreditPoints); + pointStatement.setString(2, refund ? sourceUuid : targetUuid); + if (pointStatement.executeUpdate() != 1) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + } + try (PreparedStatement journalStatement = connection.prepareStatement(updateJournal)) { + journalStatement.setString(1, refund ? REFUNDED : COMPLETED); + if (refund) journalStatement.setNull(2, java.sql.Types.INTEGER); + else journalStatement.setInt(2, adjustedCreditPoints); + journalStatement.setString(3, transferId); + if (journalStatement.executeUpdate() != 1) { + connection.rollback(); + return SettlementOutcome.INDETERMINATE; + } + } + commitAndConfirm(connection, transferId, refund ? REFUNDED : COMPLETED); + return refund ? SettlementOutcome.REFUNDED : SettlementOutcome.COMPLETED; + } + } + + enum SettlementOutcome { + COMPLETED, + REFUNDED, + INDETERMINATE + } + + /** + * Reclaims only old reservations that have never entered an external hook, + * then removes a small batch of old terminal rows. Each candidate is locked + * and checked again before a refund, so another server cannot compensate a + * transfer that it has just claimed. HOOK_STARTED rows require explicit + * reconciliation because an arbitrary listener may still have side effects. + */ + void recoverAndCleanup(long now) throws SQLException { + long reservationCutoff = now - RESERVED_RECOVERY_AGE_MILLIS; + for (String transferId : findExpiredTransferIds(RESERVED, "created_at", reservationCutoff, RECOVERY_BATCH_SIZE)) { + recoverExpiredReservation(transferId, reservationCutoff); + } + cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS, CLEANUP_BATCH_SIZE); + } + + private List findExpiredTransferIds(String state, String timeColumn, long cutoff, int limit) + throws SQLException { + String sql = "SELECT " + qi("transfer_id") + " FROM " + qiJournal() + " WHERE " + qi("state") + + " = ? AND " + qi(timeColumn) + " <= ? ORDER BY " + qi(timeColumn) + " ASC LIMIT ?"; + List transferIds = new ArrayList<>(); + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, state); + statement.setLong(2, cutoff); + statement.setInt(3, limit); + try (ResultSet result = statement.executeQuery()) { + while (result.next()) { + transferIds.add(result.getString(1)); + } + } + } + return transferIds; + } + + private boolean recoverExpiredReservation(String transferId, long reservationCutoff) throws SQLException { + String select = "SELECT " + qi("state") + ", " + qi("created_at") + ", " + qi("source_uuid") + + ", " + qi("source_points_column") + ", " + qi("debit_points") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + String sourceUuid; + String sourcePointsColumn; + int debitPoints; + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next() || !RESERVED.equals(result.getString(1)) || result.getLong(2) > reservationCutoff) { + connection.rollback(); + return false; + } + sourceUuid = result.getString(3); + sourcePointsColumn = result.getString(4); + debitPoints = result.getInt(5); + } + } + if (!isSafeColumn(sourcePointsColumn)) { + connection.rollback(); + return false; + } + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + if (refundStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + return commitAndConfirm(connection, transferId, REFUNDED); + } + } + + private void cleanupTerminalRows(long cutoff, int limit) throws SQLException { + String select = "SELECT " + qi("transfer_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("transfer_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, limit); + List transferIds = new ArrayList<>(); + try (ResultSet result = selectStatement.executeQuery()) { + while (result.next()) { + transferIds.add(result.getString(1)); + } + } + for (String transferId : transferIds) { + deleteStatement.setString(1, transferId); + deleteStatement.setString(2, COMPLETED); + deleteStatement.setString(3, REFUNDED); + deleteStatement.setLong(4, cutoff); + deleteStatement.executeUpdate(); + } + } + } + + private static boolean isSafeColumn(String column) { + return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + } + + private TransferRow find(String transferId) throws SQLException { + String sql = "SELECT " + qi("source_uuid") + ", " + qi("target_uuid") + ", " + qi("debit_points") + + ", " + qi("requested_credit_points") + ", " + qi("state") + " FROM " + qiJournal() + + " WHERE " + qi("transfer_id") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setString(1, transferId); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) return null; + return new TransferRow(result.getString(1), result.getString(2), result.getInt(3), result.getInt(4), + result.getString(5)); + } + } + } + + private boolean commitAndConfirm(Connection connection, String transferId, String expectedState) throws SQLException { + try { + connection.commit(); + return true; + } catch (SQLException ambiguousCommit) { + // A single-connection pool cannot service the confirmation lookup while + // this possibly-broken connection is still checked out. + closeQuietly(connection); + TransferRow row = find(transferId); + if (row != null && expectedState.equals(row.state)) return true; + throw ambiguousCommit; + } + } + + private void ensureSchema() throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("transfer_id") + + " VARCHAR(36) NOT NULL, " + qi("source_uuid") + " VARCHAR(37) NOT NULL, " + + qi("source_points_column") + " VARCHAR(128) NOT NULL, " + qi("target_uuid") + + " VARCHAR(37) NOT NULL, " + qi("debit_points") + " INT NOT NULL, " + + qi("requested_credit_points") + " INT NOT NULL, " + qi("adjusted_credit_points") + + " INT NULL, " + qi("state") + " VARCHAR(16) NOT NULL, " + qi("created_at") + + " BIGINT NOT NULL, " + qi("hook_started_at") + " BIGINT NULL, " + qi("hook_owner") + + " VARCHAR(64) NULL, PRIMARY KEY (" + qi("transfer_id") + + "));"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + createIndex(connection, indexName("state_created"), new String[] { "state", "created_at" }); + } + } + + private void createIndex(Connection connection, String indexName, String[] columns) throws SQLException { + StringBuilder columnSql = new StringBuilder("("); + for (int index = 0; index < columns.length; index++) { + if (index > 0) columnSql.append(", "); + columnSql.append(qi(columns[index])); + } + columnSql.append(")"); + String sql = "CREATE INDEX " + (dbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + qi(indexName) + + " ON " + qiJournal() + " " + columnSql + ";"; + try (PreparedStatement statement = connection.prepareStatement(sql)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (!isDuplicateIndex(failure)) throw failure; + } + } + + private String indexName(String suffix) { + return "vp_pt_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_" + suffix; + } + + private Connection connection() throws SQLException { + return table.getMysql().getConnectionManager().getConnection(); + } + + private String qiJournal() { + return table.qi(journalTable); + } + + private String qi(String identifier) { + return table.qi(identifier); + } + + private String uuidCast() { + return dbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; + } + + private DbType dbType() { + return table.getDbType(); + } + + private static void rollback(Connection connection) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // Preserve the original failure. The journal row remains recoverable. + } + } + + private static void closeQuietly(Connection connection) { + try { + connection.close(); + } catch (SQLException ignored) { + // The confirmation lookup below will determine whether the commit landed. + } + } + + private static boolean isDuplicate(SQLException failure) { + String state = failure.getSQLState(); + return "23505".equals(state) || failure.getErrorCode() == 1062; + } + + private static boolean isDuplicateIndex(SQLException failure) { + return failure.getErrorCode() == 1061 || "42P07".equals(failure.getSQLState()); + } + + private record TransferRow(String sourceUuid, String targetUuid, int debitPoints, int requestedCreditPoints, + String state) { + boolean matches(String source, String target, int debit, int requestedCredit) { + return sourceUuid.equals(source) && targetUuid.equals(target) && debitPoints == debit + && requestedCreditPoints == requestedCredit; + } + } +} 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 000000000..cff6bb545 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -0,0 +1,398 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertFalse; +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 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 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); + 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(); + } + + @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 index 545f481d9..a93ae79bc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -6,19 +6,20 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.CALLS_REAL_METHODS; 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.inOrder; import static org.mockito.Mockito.never; import static org.mockito.Mockito.doReturn; +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.doAnswer; import java.lang.reflect.Field; import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.atomic.AtomicReference; @@ -27,11 +28,14 @@ import org.bukkit.plugin.PluginManager; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; -import org.mockito.MockedStatic; import org.mockito.InOrder; +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.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.mysql.ConnectionManager; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; @@ -71,14 +75,7 @@ void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throw @Test void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { - PointFixture fixture = pointFixture(); - VotingPluginUser target = mock(VotingPluginUser.class); - when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); - when(target.getPointsPath()).thenReturn("Points"); - PreparedStatement credit = mock(PreparedStatement.class); - when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); - when(fixture.statement.executeUpdate()).thenReturn(1); - when(credit.executeUpdate()).thenReturn(1); + SagaFixture fixture = sagaFixture(true); AtomicReference result = new AtomicReference<>(); AtomicReference eventThread = new AtomicReference<>(); @@ -89,7 +86,7 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { eventThread.set(Thread.currentThread()); return null; }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); - fixture.user.transferPoints(target, 10, result::set); + fixture.user.transferPoints(fixture.target, 10, result::set); ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); Thread persistenceThread = Thread.currentThread(); @@ -106,14 +103,7 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { @Test void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exception { - PointFixture fixture = pointFixture(); - VotingPluginUser target = mock(VotingPluginUser.class); - when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); - when(target.getPointsPath()).thenReturn("Points"); - PreparedStatement credit = mock(PreparedStatement.class); - when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); - when(fixture.statement.executeUpdate()).thenReturn(1); - when(credit.executeUpdate()).thenReturn(1); + SagaFixture fixture = sagaFixture(true); AtomicReference result = new AtomicReference<>(); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { @@ -124,7 +114,7 @@ void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exc event.setPoints(4); return null; }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); - fixture.user.transferPoints(target, 10, result::set); + fixture.user.transferPoints(fixture.target, 10, result::set); ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); @@ -132,31 +122,25 @@ void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exc ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); - InOrder transferOrder = inOrder(fixture.statement, pluginManager, credit); - transferOrder.verify(fixture.statement).executeUpdate(); + InOrder transferOrder = inOrder(fixture.debit, pluginManager, fixture.settlementPoint); + transferOrder.verify(fixture.debit).executeUpdate(); transferOrder.verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); - transferOrder.verify(credit).executeUpdate(); + transferOrder.verify(fixture.settlementPoint).executeUpdate(); } assertTrue(result.get()); - verify(credit).setInt(1, 4); + verify(fixture.settlementPoint).setInt(1, 4); } @Test void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws Exception { - PointFixture fixture = pointFixture(); - VotingPluginUser target = mock(VotingPluginUser.class); - when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); - when(target.getPointsPath()).thenReturn("Points"); - PreparedStatement credit = mock(PreparedStatement.class); - when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); - when(fixture.statement.executeUpdate()).thenReturn(0); + SagaFixture fixture = sagaFixture(false); AtomicReference result = new AtomicReference<>(); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { PluginManager pluginManager = mock(PluginManager.class); bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); - fixture.user.transferPoints(target, 10, result::set); + fixture.user.transferPoints(fixture.target, 10, result::set); ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); @@ -166,7 +150,7 @@ void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws E entityWork.getValue().run(); verify(pluginManager, never()).callEvent(any(PlayerReceivePointsEvent.class)); - verify(credit, never()).executeUpdate(); + verify(fixture.settlementPoint, never()).executeUpdate(); } assertFalse(result.get()); @@ -174,18 +158,7 @@ void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws E @Test void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { - PointFixture fixture; - try { - fixture = pointFixture(); - } catch (Exception failure) { - throw new AssertionError(failure); - } - VotingPluginUser target = mock(VotingPluginUser.class); - when(target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); - when(target.getPointsPath()).thenReturn("Points"); - PreparedStatement credit = mock(PreparedStatement.class); - when(fixture.connection.prepareStatement(anyString())).thenReturn(fixture.statement, credit); - when(fixture.statement.executeUpdate()).thenReturn(1); + SagaFixture fixture = sagaFixture(true); AtomicReference result = new AtomicReference<>(); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { @@ -196,7 +169,7 @@ void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { event.setCancelled(true); return null; }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); - fixture.user.transferPoints(target, 10, result::set); + fixture.user.transferPoints(fixture.target, 10, result::set); ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); @@ -206,14 +179,62 @@ void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { completion.getValue().run(); verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); - verify(fixture.connection).rollback(); - verify(fixture.connection, never()).commit(); - verify(credit, never()).executeUpdate(); + verify(fixture.settlementPoint).executeUpdate(); + verify(fixture.settlementPoint).setInt(1, 10); + verify(fixture.settlementPoint).setString(2, fixture.user.getUUID()); + verify(fixture.settlement).commit(); } assertTrue(Boolean.FALSE.equals(result.get())); } + @Test + void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustment() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + VotingPluginUser target = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(target, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000002").when(target).getUUID(); + doReturn("Points").when(target).getPointsPath(); + doReturn(false).when(target).isCached(); + UserData targetData = mock(UserData.class); + doReturn(targetData).when(target).getUserData(); + doAnswer(invocation -> { + try (Connection ignored = fixture.manager.getConnection()) { + return 37; + } + }).when(targetData).getInt("Points"); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + assertEquals(37, event.getPlayer().getPoints()); + event.setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + fixture.user.transferPoints(target, 10, result::set); + 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(); + } + + InOrder order = org.mockito.Mockito.inOrder(fixture.reservation, fixture.claim, fixture.listenerRead, + fixture.settlement); + order.verify(fixture.reservation).close(); + order.verify(fixture.claim).close(); + order.verify(fixture.listenerRead).close(); + verify(fixture.settlement).commit(); + assertEquals(Boolean.TRUE, result.get()); + } + private static PointFixture pointFixture() throws Exception { PointFixture fixture = new PointFixture(); fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -246,6 +267,178 @@ private static PointFixture pointFixture() throws Exception { return fixture; } + private static SagaFixture sagaFixture(boolean debitSucceeds) throws Exception { + SagaFixture fixture = new SagaFixture(); + fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.persistence = mock(ScheduledExecutorService.class); + fixture.scheduler = mock(BukkitScheduler.class); + fixture.player = mock(Player.class); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); + fixture.manager = mock(ConnectionManager.class); + fixture.schema = mock(Connection.class); + fixture.recoveryReserved = mock(Connection.class); + fixture.cleanup = mock(Connection.class); + fixture.lookup = mock(Connection.class); + fixture.reservation = mock(Connection.class); + fixture.claim = mock(Connection.class); + fixture.settlement = mock(Connection.class); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(fixture.plugin.getMysql()).thenReturn(fixture.table); + when(fixture.plugin.getTimer()).thenReturn(fixture.persistence); + when(fixture.plugin.getBukkitScheduler()).thenReturn(fixture.scheduler); + 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()).thenReturn(fixture.manager); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.settlement); + when(fixture.schema.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class)); + configureJournalMaintenance(fixture.recoveryReserved, fixture.cleanup); + + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup); + PreparedStatement insert = mock(PreparedStatement.class); + fixture.debit = mock(PreparedStatement.class); + when(fixture.debit.executeUpdate()).thenReturn(debitSucceeds ? 1 : 0); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, fixture.debit); + + PreparedStatement claimSelect = mock(PreparedStatement.class); + fixture.claimUpdate = mock(PreparedStatement.class); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(fixture.claimUpdate).setString(eq(2), anyString()); + ResultSet reserved = mock(ResultSet.class); + when(reserved.next()).thenReturn(true); + when(reserved.getString(1)).thenReturn("RESERVED"); + when(reserved.getString(2)).thenReturn(null); + when(claimSelect.executeQuery()).thenReturn(reserved); + when(fixture.claimUpdate.executeUpdate()).thenReturn(1); + when(fixture.claim.prepareStatement(anyString())).thenReturn(claimSelect, fixture.claimUpdate); + + PreparedStatement settleSelect = mock(PreparedStatement.class); + fixture.settlementPoint = mock(PreparedStatement.class); + PreparedStatement settleJournal = mock(PreparedStatement.class); + ResultSet started = mock(ResultSet.class); + when(started.next()).thenReturn(true); + when(started.getString(1)).thenReturn("HOOK_STARTED"); + when(started.getString(2)).thenAnswer(invocation -> owner.get()); + when(settleSelect.executeQuery()).thenReturn(started); + when(fixture.settlementPoint.executeUpdate()).thenReturn(1); + when(settleJournal.executeUpdate()).thenReturn(1); + when(fixture.settlement.prepareStatement(anyString())).thenReturn(settleSelect, fixture.settlementPoint, + settleJournal); + + fixture.user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(fixture.user, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn("Points").when(fixture.user).getPointsPath(); + doReturn(fixture.player).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isCached(); + fixture.target = mock(VotingPluginUser.class); + when(fixture.target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(fixture.target.getPointsPath()).thenReturn("Points"); + return fixture; + } + + private static TransferSchedulingFixture transferSchedulingFixture() throws Exception { + TransferSchedulingFixture fixture = new TransferSchedulingFixture(); + fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + fixture.persistence = mock(ScheduledExecutorService.class); + fixture.scheduler = mock(BukkitScheduler.class); + fixture.player = mock(Player.class); + fixture.table = mock(MySQL.class); + fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); + fixture.manager = mock(ConnectionManager.class); + fixture.schema = mock(Connection.class); + fixture.recoveryReserved = mock(Connection.class); + fixture.cleanup = mock(Connection.class); + fixture.lookup = mock(Connection.class); + fixture.reservation = mock(Connection.class); + fixture.claim = mock(Connection.class); + fixture.listenerRead = mock(Connection.class); + fixture.settlement = mock(Connection.class); + when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + when(fixture.plugin.getMysql()).thenReturn(fixture.table); + when(fixture.plugin.getTimer()).thenReturn(fixture.persistence); + when(fixture.plugin.getBukkitScheduler()).thenReturn(fixture.scheduler); + 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()).thenReturn(fixture.manager); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.listenerRead, fixture.settlement); + + when(fixture.schema.prepareStatement(anyString())).thenReturn(mock(PreparedStatement.class)); + configureJournalMaintenance(fixture.recoveryReserved, fixture.cleanup); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.lookup.prepareStatement(anyString())).thenReturn(lookup); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.reservation.prepareStatement(anyString())).thenReturn(insert, debit); + PreparedStatement claimSelect = mock(PreparedStatement.class); + PreparedStatement claimUpdate = mock(PreparedStatement.class); + AtomicReference owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(claimUpdate).setString(eq(2), anyString()); + ResultSet reserved = mock(ResultSet.class); + when(reserved.next()).thenReturn(true); + when(reserved.getString(1)).thenReturn("RESERVED"); + when(reserved.getString(2)).thenReturn(null); + when(claimSelect.executeQuery()).thenReturn(reserved); + when(claimUpdate.executeUpdate()).thenReturn(1); + when(fixture.claim.prepareStatement(anyString())).thenReturn(claimSelect, claimUpdate); + PreparedStatement settleSelect = mock(PreparedStatement.class); + PreparedStatement settleCredit = mock(PreparedStatement.class); + PreparedStatement settleJournal = mock(PreparedStatement.class); + ResultSet started = mock(ResultSet.class); + when(started.next()).thenReturn(true); + when(started.getString(1)).thenReturn("HOOK_STARTED"); + when(started.getString(2)).thenAnswer(invocation -> owner.get()); + when(settleSelect.executeQuery()).thenReturn(started); + when(settleCredit.executeUpdate()).thenReturn(1); + when(settleJournal.executeUpdate()).thenReturn(1); + when(fixture.settlement.prepareStatement(anyString())).thenReturn(settleSelect, settleCredit, settleJournal); + fixture.user = mock(VotingPluginUser.class, CALLS_REAL_METHODS); + Field pluginField = VotingPluginUser.class.getDeclaredField("plugin"); + pluginField.setAccessible(true); + pluginField.set(fixture.user, fixture.plugin); + doReturn("00000000-0000-0000-0000-000000000001").when(fixture.user).getUUID(); + doReturn("Points").when(fixture.user).getPointsPath(); + doReturn(fixture.player).when(fixture.user).getPlayer(); + doReturn(false).when(fixture.user).isCached(); + return fixture; + } + + private static void configureJournalMaintenance(Connection reservedCandidates, Connection cleanup) throws Exception { + PreparedStatement reservedQuery = mock(PreparedStatement.class); + PreparedStatement cleanupQuery = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet noRows = mock(ResultSet.class); + ResultSet noCleanupRows = mock(ResultSet.class); + when(noRows.next()).thenReturn(false); + when(noCleanupRows.next()).thenReturn(false); + when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedQuery); + when(reservedQuery.executeQuery()).thenReturn(noRows); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupQuery, cleanupDelete); + when(cleanupQuery.executeQuery()).thenReturn(noCleanupRows); + } + private static final class PointFixture { VotingPluginMain plugin; ScheduledExecutorService persistence; @@ -258,4 +451,45 @@ private static final class PointFixture { VotingPluginUser user; } + private static final class SagaFixture { + VotingPluginMain plugin; + ScheduledExecutorService persistence; + BukkitScheduler scheduler; + Player player; + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + ConnectionManager manager; + Connection schema; + Connection recoveryReserved; + Connection cleanup; + Connection lookup; + Connection reservation; + Connection claim; + Connection settlement; + PreparedStatement debit; + PreparedStatement claimUpdate; + PreparedStatement settlementPoint; + VotingPluginUser user; + VotingPluginUser target; + } + + private static final class TransferSchedulingFixture { + VotingPluginMain plugin; + ScheduledExecutorService persistence; + BukkitScheduler scheduler; + Player player; + MySQL table; + com.bencodez.simpleapi.sql.mysql.MySQL sql; + ConnectionManager manager; + Connection schema; + Connection recoveryReserved; + Connection cleanup; + Connection lookup; + Connection reservation; + Connection claim; + Connection listenerRead; + Connection settlement; + VotingPluginUser user; + } + } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index c3d92639c..cdb392d4f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -33,6 +33,7 @@ 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.advancedcore.api.rewards.RewardHandler; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition; @@ -211,6 +212,9 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti when(connection.prepareStatement(anyString())).thenReturn(statement); when(statement.executeUpdate()).thenReturn(1); VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.isEnabled()).thenReturn(true); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(persistenceExecutor); com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = @@ -260,8 +264,8 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti } assertEquals(VoteShopPurchaseResult.SUCCESS, result.get()); - verify(plugin.getRewardHandler()).giveReward(eq(user), eq(oldShopData), eq("Shop.old-item.Rewards"), any()); - verify(plugin.getRewardHandler(), never()).giveReward(eq(user), eq(reloadedShopData), anyString(), any()); + verify(rewardHandler).giveReward(eq(user), eq(oldShopData), eq("Shop.old-item.Rewards"), any()); + verify(rewardHandler, never()).giveReward(eq(user), eq(reloadedShopData), anyString(), any()); } @Test From dc25f953f7d1fb25d490f7d289327ea0febc3430 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:27:51 -0600 Subject: [PATCH 16/74] Close shared point mutation consistency gaps --- .../user/SharedMysqlPointMutator.java | 40 ++++++++++++++- .../votingplugin/user/VotingPluginUser.java | 11 ++--- .../service/VoteShopPurchaseService.java | 11 +++-- .../user/SharedMysqlPointMutatorTest.java | 23 +++++++-- .../VotingPluginUserPointSchedulingTest.java | 49 +++++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 32 ++++++++++++ 6 files changed, 150 insertions(+), 16 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index e91558eba..e21e744a1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -24,8 +24,15 @@ boolean applies() { && !plugin.getBungeeSettings().isPerServerPoints(); } - void add(VotingPluginUser user, int amount, boolean async) { - run(() -> update(user, amount, false), async); + int add(VotingPluginUser user, int amount, boolean async) { + if (async) { + int predictedTotal = user.getPoints() + amount; + run(() -> update(user, amount, false), true); + // The mutation has not happened yet, so the historical asynchronous API + // returns its predicted post-event total without blocking for storage. + return predictedTotal; + } + return addAndReadCommitted(user, amount); } void set(VotingPluginUser user, int value, boolean async) { @@ -192,6 +199,35 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat } } + /** + * Adds points and reads the resulting value through the same JDBC connection. + * This bypasses the wrapper's temporary user-data cache, which can remain stale + * even when the caller requests {@code NO_CACHE}. + */ + private int addAndReadCommitted(VotingPluginUser user, int amount) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = " + + table.qi(points) + " + ? WHERE " + uuidMatch; + String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch; + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement updateStatement = connection.prepareStatement(update); + PreparedStatement readStatement = connection.prepareStatement(read)) { + updateStatement.setInt(1, amount); + updateStatement.setString(2, user.getUUID()); + if (updateStatement.executeUpdate() != 1) return user.getPoints(); + readStatement.setString(1, user.getUUID()); + try (java.sql.ResultSet result = readStatement.executeQuery()) { + return result.next() ? result.getInt(1) : user.getPoints(); + } + } catch (SQLException failure) { + logFailure(failure); + return user.getPoints(); + } + } + private void setAbsolute(VotingPluginUser user, int value) { drainCache(user); MySQL table = plugin.getMysql(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index c2cc59f37..593060320 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -217,15 +217,14 @@ public synchronized int addPoints(int value, boolean async) { if (event.isCancelled()) { return getPoints(); } - int newTotal = getPoints() + event.getPoints(); SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.applies()) { - sharedPoints.add(this, event.getPoints(), async); - } else { - setPoints(newTotal, async); + return sharedPoints.add(this, event.getPoints(), async); } - return newTotal; - } + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, async); + return newTotal; + } /** * Adds one to the total votes. 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 2849d381d..89f88040d 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 @@ -294,17 +294,20 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item statement.setString(2, user.getUUID()); statement.setInt(3, item.getCost()); if (limitColumn != null) statement.setInt(4, item.getLimit()); - if (statement.executeUpdate() != 1) { - return sharedMysqlFailure(user, item, limitColumn); + if (statement.executeUpdate() == 1) { + refreshPurchaseCache(user, pointsColumn, limitColumn); + return VoteShopPurchaseResult.SUCCESS; } - refreshPurchaseCache(user, pointsColumn, limitColumn); - return VoteShopPurchaseResult.SUCCESS; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically debit vote shop points: " + failure.getClass().getSimpleName()); plugin.debug(failure); return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; } + // The classification performs a fresh NO_CACHE database read. It must only + // acquire that connection after the conditional-debit handle has returned to + // the pool, which may be configured with a single connection. + return sharedMysqlFailure(user, item, limitColumn); } private void refundSharedMysqlDebit(VotingPluginUser user, VoteShopItem item) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index e519dc1dd..b440445c8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -2,6 +2,7 @@ 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.Mockito.mock; import static org.mockito.Mockito.verify; @@ -17,6 +18,8 @@ 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.userstorage.mysql.MySQL; import com.bencodez.votingplugin.VotingPluginMain; @@ -50,11 +53,17 @@ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exce 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); + 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); @@ -65,14 +74,20 @@ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exce 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); - new SharedMysqlPointMutator(plugin).add(user, 10, false); + assertEquals(73, new SharedMysqlPointMutator(plugin).add(user, 10, false)); ArgumentCaptor query = ArgumentCaptor.forClass(String.class); - verify(connection).prepareStatement(query.capture()); - assertTrue(query.getValue().contains("`Points` = `Points` + ?")); + 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)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index a93ae79bc..bd5124b19 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -34,6 +34,7 @@ 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.simpleapi.sql.mysql.ConnectionManager; import com.bencodez.simpleapi.scheduler.BukkitScheduler; @@ -41,6 +42,54 @@ import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; class VotingPluginUserPointSchedulingTest { + @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 sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { + PointFixture fixture = pointFixture(); + doReturn(10).when(fixture.user).getPoints(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setPoints(7); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + assertEquals(17, fixture.user.addPoints(5, true)); + } + + verify(fixture.persistence).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + @Test void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index cdb392d4f..435425875 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -31,6 +31,8 @@ import org.mockito.InOrder; 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.advancedcore.api.rewards.RewardHandler; @@ -172,6 +174,36 @@ void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), null); } + @Test + void sharedMysqlFailureReleasesDebitConnectionBeforeClassifyingTheLimit() 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); + VotingPluginUser user = purchaseUser(); + UserData data = mock(UserData.class); + when(user.getUserData()).thenReturn(data); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE)).thenReturn(1); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("daily"); + + assertEquals(VoteShopPurchaseResult.LIMIT_REACHED, + new VoteShopPurchaseService(sharedMysqlPlugin(table), null).debitSharedMysql(user, item)); + + InOrder closeThenClassify = inOrder(connection, data); + closeThenClassify.verify(connection).close(); + closeThenClassify.verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + } + @Test void sharedMysqlPurchaseQueuesDatabaseWorkOffCallingThread() throws Exception { MySQL table = mock(MySQL.class); From 520aa142e70a9008fda54bf934bcf3125c5c1a13 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 05:35:20 -0600 Subject: [PATCH 17/74] Journal shared vote shop purchases --- .../user/SharedMysqlPointMutator.java | 9 + .../votingplugin/user/VotingPluginUser.java | 2 +- .../voteshop/VoteShopManager.java | 17 + .../service/SharedMysqlPurchaseJournal.java | 381 ++++++++++++++++++ .../service/VoteShopPurchaseService.java | 184 +++++++-- .../user/SharedMysqlPointMutatorTest.java | 31 ++ .../VotingPluginUserPointSchedulingTest.java | 16 + .../voteshop/VoteShopManagerTest.java | 29 ++ .../SharedMysqlPurchaseJournalTest.java | 217 ++++++++++ .../service/VoteShopPurchaseServiceTest.java | 146 ++++++- 10 files changed, 980 insertions(+), 52 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index e21e744a1..114857aaf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -47,6 +47,15 @@ boolean remove(VotingPluginUser user, int amount) { return update(user, -amount, true); } + boolean remove(VotingPluginUser user, int amount, boolean async) { + if (!async) return remove(user, amount); + boolean predictedSuccess = user.getPoints() >= amount; + run(() -> update(user, -amount, true), true); + // Preserve the historical asynchronous API contract: the caller receives + // the cached prediction while the conditional database debit runs later. + return predictedSuccess; + } + boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { return transfer(source, target, amount, amount); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 593060320..42afcea45 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1327,7 +1327,7 @@ public boolean removePoints(int points) { */ public boolean removePoints(int points, boolean async) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) return sharedPoints.remove(this, points); + if (sharedPoints.applies()) return sharedPoints.remove(this, points, async); if (getPoints() >= points) { setPoints(getPoints() - points, async); return true; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java index db61b82b2..0be8cad6b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.voteshop; +import java.util.concurrent.TimeUnit; import java.util.function.Consumer; import org.bukkit.entity.Player; @@ -38,6 +39,22 @@ public class VoteShopManager { public VoteShopManager(VotingPluginMain plugin) { this.plugin = plugin; reload(); + startSharedPurchaseRecovery(); + } + + /** + * Recover stale durable shared-MySQL purchase reservations even when no player + * opens the vote shop after a restart. The inherited persistence executor is + * shut down with the plugin, so this task has no independent lifecycle. + */ + private void startSharedPurchaseRecovery() { + scheduleSharedPurchaseRecovery(plugin); + } + + static void scheduleSharedPurchaseRecovery(VotingPluginMain plugin) { + plugin.getTimer().execute(() -> VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin)); + plugin.getTimer().scheduleWithFixedDelay( + () -> VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin), 1L, 1L, TimeUnit.MINUTES); } /** 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 000000000..387a8ccb3 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -0,0 +1,381 @@ +package com.bencodez.votingplugin.voteshop.service; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +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 COMPLETED = "COMPLETED"; + private static final String REFUNDED = "REFUNDED"; + 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; + + private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); + private static final Set INITIALIZED = new HashSet<>(); + + private final MySQL table; + private final String journalTable; + + SharedMysqlPurchaseJournal(MySQL table, boolean initializeSchema) throws SQLException { + this.table = table; + journalTable = table.getTableName() + "_VoteShopPurchases"; + if (initializeSchema) ensureSchema(); + } + + 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, + 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("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 (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); + insertStatement.setString(7, PENDING); + insertStatement.setLong(8, 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; + } + } + } + + /** + * 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, null); + } + + /** Refunds only a debit whose reward hook has not started. */ + boolean refundPending(String purchaseId) throws SQLException { + return setTerminal(purchaseId, REFUNDED, PENDING); + } + + /** + * Compensates a claimed purchase only when the local scheduler guard proves + * that its reward callback can no longer start. This is never used by stale + * recovery, which must leave arbitrary HOOK_STARTED work for reconciliation. + */ + boolean refundClaimedBeforeReward(String purchaseId) throws SQLException { + return setTerminal(purchaseId, REFUNDED, HOOK_STARTED); + } + + private boolean setTerminal(String purchaseId, String terminalState, String refundableState) throws SQLException { + boolean refund = REFUNDED.equals(terminalState); + String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column") + + ", " + qi("limit_column") + ", " + qi("cost") + " 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 false; + } + String state = result.getString(1); + if (COMPLETED.equals(state) || REFUNDED.equals(state)) { + rollback(connection); + return terminalState.equals(state); + } + if (refund && !refundableState.equals(state)) { + rollback(connection); + return false; + } + if (!refund && !HOOK_STARTED.equals(state)) { + rollback(connection); + return false; + } + String uuid = result.getString(2); + String pointsColumn = result.getString(3); + String limitColumn = result.getString(4); + int cost = result.getInt(5); + if (refund) refund(connection, uuid, pointsColumn, limitColumn, cost); + } + } + 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 false; + } + } + connection.commit(); + return true; + } catch (SQLException failure) { + throw failure; + } + } + + private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost) + 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 (limitColumn != null) { + 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"); + } + } + + void 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)); + } + } + for (String purchaseId : pending) refundPending(purchaseId); + cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS); + } + + 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("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(); + 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 Connection connection() throws SQLException { + return table.getMysql().getConnectionManager().getConnection(); + } + + private String qiJournal() { return table.qi(journalTable); } + private String qi(String identifier) { return table.qi(identifier); } + private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } + + private static boolean isSafeColumn(String column) { + return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + } + + 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) { + } + + 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/VoteShopPurchaseService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseService.java index 89f88040d..faba31fc7 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 @@ -156,32 +156,50 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, FileConfiguration shopData = plugin.getShopFile().getData(); HashMap placeholders = purchasePlaceholders(item); plugin.getTimer().execute(() -> { - VoteShopPurchaseResult debit; + SharedPurchaseDebit debit; synchronized (purchaseLock(user.getUUID())) { - debit = debitSharedMysql(user, item); + debit = reserveSharedMysqlPurchase(user, item); } - if (debit != VoteShopPurchaseResult.SUCCESS) { - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit), player); + if (debit.result() != VoteShopPurchaseResult.SUCCESS) { + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit.result()), player); return; } - completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion); + completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit); }); } private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item, HashMap placeholders, FileConfiguration shopData, - Consumer completion) { + Consumer completion, SharedPurchaseDebit debit) { CountDownLatch completed = new CountDownLatch(1); AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); try { + /* Claim on the persistence worker before entering the entity scheduler. + * A JDBC pool wait or database lock must never block a Bukkit/Folia entity + * lane; the scheduled callback below performs reward/UI work only. */ + SharedMysqlPurchaseJournal.ClaimOutcome claim = claimSharedMysqlPurchase(debit); + if (claim == SharedMysqlPurchaseJournal.ClaimOutcome.NOT_CLAIMED) { + refundSharedMysqlDebit(user, debit, false); + return; + } + if (claim == SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE) { + plugin.getLogger().severe("Shared MySQL vote shop purchase " + debit.purchaseId() + + " has an indeterminate reward claim; retaining it for reconciliation"); + return; + } CompletableFuture scheduled = plugin.getBukkitScheduler().getFoliaLib().getImpl() .runAtEntityWithFallback(player, ignored -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; try { completePurchase(player, user, item, placeholders, shopData); - completion.accept(VoteShopPurchaseResult.SUCCESS); + /* + * The entity callback owns only reward/UI work. Queue the terminal + * journal update back to the persistence executor after the reward + * completes, so a JDBC pool wait cannot stall an entity lane. + */ + plugin.getTimer().execute(() -> settleSharedMysqlPurchase(player, completion, debit)); } finally { - state.set(COMPLETION_FINISHED); + state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); completed.countDown(); } }, () -> requestCompensation(state, completed)); @@ -191,22 +209,39 @@ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, V while (!completed.await(100, TimeUnit.MILLISECONDS)) { if (!plugin.isEnabled()) requestCompensation(state, completed); } - if (state.get() == COMPLETION_COMPENSATING) refundSharedMysqlDebit(user, item); + if (state.get() == COMPLETION_COMPENSATING) refundSharedMysqlDebit(user, debit, true); } catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); - if (requestCompensation(state, completed)) refundSharedMysqlDebit(user, item); + if (compensationRequiredAfterInterruption(state, completed)) refundSharedMysqlDebit(user, debit, true); } catch (RuntimeException schedulingFailure) { - if (requestCompensation(state, completed)) refundSharedMysqlDebit(user, item); + requestCompensation(state, completed); + if (state.get() == COMPLETION_COMPENSATING) refundSharedMysqlDebit(user, debit, true); plugin.debug(schedulingFailure); } } + private void settleSharedMysqlPurchase(Player player, Consumer completion, + SharedPurchaseDebit debit) { + completeSharedMysqlPurchase(debit); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(VoteShopPurchaseResult.SUCCESS), player); + } + private static boolean requestCompensation(AtomicInteger state, CountDownLatch completed) { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return false; completed.countDown(); return true; } + /** + * An entity scheduler fallback can request compensation just before the + * persistence worker is interrupted. The latter still owns the debit and + * must perform the refund even though it did not win the state transition. + */ + static boolean compensationRequiredAfterInterruption(AtomicInteger state, CountDownLatch completed) { + requestCompensation(state, completed); + return state.get() == COMPLETION_COMPENSATING; + } + private HashMap purchasePlaceholders(VoteShopItem item) { HashMap placeholders = new HashMap(); placeholders.put("identifier", item.getIdentifierName()); @@ -256,25 +291,39 @@ VoteShopPurchaseResult debitForPurchase(VotingPluginUser user, VoteShopItem item } private boolean usesSharedMysqlPoints() { + return usesSharedMysqlPoints(plugin); + } + + private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints(); } + /** Runs bounded stale-purchase recovery from the plugin lifecycle executor. */ + public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { + if (!usesSharedMysqlPoints(plugin)) return; + try { + SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).recoverAndCleanup(System.currentTimeMillis()); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to recover pending shared MySQL vote shop purchases: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + 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; if (user.isCached()) { - // dump() waits for a cache batch that has already left its queue. Removing - // the drained cache also prevents an older absolute write from racing the - // conditional debit on the shared database. user.getCache().dump(); plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); } - if (limitColumn != null) { - table.checkColumn(limitColumn, DataType.INTEGER); - } - + 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) { @@ -284,50 +333,99 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item 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) < ?"); - } + if (limitColumn != null) sql.append(" AND COALESCE(").append(table.qi(limitColumn)).append(", 0) < ?"); + boolean debited = false; try (Connection connection = table.getMysql().getConnectionManager().getConnection(); 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()); - if (statement.executeUpdate() == 1) { - refreshPurchaseCache(user, pointsColumn, limitColumn); - return VoteShopPurchaseResult.SUCCESS; - } + debited = statement.executeUpdate() == 1; } catch (SQLException failure) { plugin.getLogger().severe("Unable to atomically debit vote shop points: " + failure.getClass().getSimpleName()); plugin.debug(failure); return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; } - // The classification performs a fresh NO_CACHE database read. It must only - // acquire that connection after the conditional-debit handle has returned to - // the pool, which may be configured with a single connection. + 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 void refundSharedMysqlDebit(VotingPluginUser user, VoteShopItem item) { + private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item) { MySQL table = plugin.getMysql(); String pointsColumn = user.getPointsPath(); String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; - StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ") - .append(table.qi(pointsColumn)).append(" = ").append(table.qi(pointsColumn)).append(" + ?"); + if (user.isCached()) { + // dump() waits for a cache batch that has already left its queue. Removing + // the drained cache also prevents an older absolute write from racing the + // conditional debit on the shared database. + user.getCache().dump(); + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } if (limitColumn != null) { - sql.append(", ").append(table.qi(limitColumn)).append(" = GREATEST(COALESCE(") - .append(table.qi(limitColumn)).append(", 0) - 1, 0)"); + table.checkColumn(limitColumn, DataType.INTEGER); } - sql.append(" WHERE ").append(table.qi("uuid")) - .append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); - PreparedStatement statement = connection.prepareStatement(sql.toString())) { - statement.setInt(1, item.getCost()); - statement.setString(2, user.getUUID()); - statement.executeUpdate(); - refreshPurchaseCache(user, pointsColumn, limitColumn); + try { + SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); + journal.recoverAndCleanup(System.currentTimeMillis()); + String purchaseId = UUID.randomUUID().toString(); + if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), + 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) { + plugin.getLogger().severe("Unable to atomically debit vote shop points: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + return new SharedPurchaseDebit(VoteShopPurchaseResult.NOT_ENOUGH_POINTS, null, null, null, null); + } + return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, 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 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 void refundSharedMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit, + boolean schedulerProvesRewardCannotRun) { + try { + boolean refunded = schedulerProvesRewardCannotRun + ? debit.journal().refundClaimedBeforeReward(debit.purchaseId()) + : debit.journal().refundPending(debit.purchaseId()); + if (refunded) { + // refundPending() closes its transaction handle before any NO_CACHE + // cache refresh, including when the cache reappears concurrently. + refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); + } } catch (SQLException failure) { plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: " + failure.getClass().getSimpleName()); @@ -352,6 +450,10 @@ private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, St } } + private record SharedPurchaseDebit(VoteShopPurchaseResult result, SharedMysqlPurchaseJournal journal, + String purchaseId, String pointsColumn, String limitColumn) { + } + Object purchaseLock(String uuid) { return PURCHASE_LOCKS[(uuid == null ? 0 : uuid.hashCode()) & (PURCHASE_LOCK_STRIPES - 1)]; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index b440445c8..40d17f291 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -46,6 +46,37 @@ void removeReportsARejectedConditionalDebit() throws Exception { assertFalse(new SharedMysqlPointMutator(plugin).remove(user, 10)); } + @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); + when(user.getPoints()).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(); + + work.getValue().run(); + verify(sql.getConnectionManager()).getConnection(); + verify(statement).executeUpdate(); + } + @Test void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exception { MySQL table = mock(MySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index bd5124b19..271e156a4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -101,6 +101,22 @@ void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { verify(fixture.statement).executeUpdate(); } + @Test + void sharedAsyncRemoveKeepsJdbcOffTheCallerThread() throws Exception { + PointFixture fixture = pointFixture(); + doReturn(20).when(fixture.user).getPoints(); + when(fixture.statement.executeUpdate()).thenReturn(1); + + assertTrue(fixture.user.removePoints(10, true)); + + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + persistenceWork.getValue().run(); + verify(fixture.sql.getConnectionManager()).getConnection(); + verify(fixture.statement).executeUpdate(); + } + @Test void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java new file mode 100644 index 000000000..7e816deef --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java @@ -0,0 +1,29 @@ +package com.bencodez.votingplugin.voteshop; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Test; + +import com.bencodez.votingplugin.VotingPluginMain; + +class VoteShopManagerTest { + @Test + void schedulesStartupAndBoundedPeriodicSharedPurchaseRecovery() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + + VoteShopManager.scheduleSharedPurchaseRecovery(plugin); + + verify(timer).execute(any(Runnable.class)); + verify(timer).scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), + org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java new file mode 100644 index 000000000..bdf91ece9 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -0,0 +1,217 @@ +package com.bencodez.votingplugin.voteshop.service; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; +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 java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; + +class SharedMysqlPurchaseJournalTest { + @Test + void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { + Fixture fixture = fixture(); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(insert, debit); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.reserve("purchase-1", "player", "Points", "VoteShopLimitdaily", 10, 1, 100L)); + + verify(insert).setString(7, "PENDING"); + verify(debit).setInt(1, 10); + verify(fixture.work).commit(); + } + + @Test + void recoveryRefundsOnlyExpiredPendingPurchase() throws Exception { + Fixture fixture = fixture(); + Connection candidates = mock(Connection.class); + Connection refund = mock(Connection.class); + Connection cleanup = mock(Connection.class); + PreparedStatement candidateStatement = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + ResultSet expiredPending = ids("expired-pending"); + ResultSet pending = pendingRow(); + ResultSet noTerminalRows = ids(); + when(candidates.prepareStatement(anyString())).thenReturn(candidateStatement); + when(candidateStatement.executeQuery()).thenReturn(expiredPending); + when(refund.prepareStatement(anyString())).thenReturn(select, credit, terminal); + when(select.executeQuery()).thenReturn(pending); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(candidates, refund, cleanup); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + journal.recoverAndCleanup(SharedMysqlPurchaseJournal.PENDING_RECOVERY_AGE_MILLIS + 1L); + + verify(credit).setInt(1, 10); + verify(credit).setString(2, "player"); + verify(terminal).setString(1, "REFUNDED"); + verify(refund).commit(); + } + + @Test + void hookStartedPurchaseIsNeverRefundedByCompensation() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet hookStarted = hookStartedRow(); + when(fixture.work.prepareStatement(anyString())).thenReturn(select); + when(select.executeQuery()).thenReturn(hookStarted); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertFalse(journal.refundPending("claimed-purchase")); + } + + @Test + void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet hookStarted = pendingRow("HOOK_STARTED"); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, credit, terminal); + when(select.executeQuery()).thenReturn(hookStarted); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundClaimedBeforeReward("scheduler-rejected")); + + verify(terminal).setString(1, "REFUNDED"); + } + + @Test + void ambiguousReservationCommitIsConfirmedAfterItsConnectionIsReleased() throws Exception { + Fixture fixture = fixture(); + Connection reservation = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet committed = mock(ResultSet.class); + when(reservation.prepareStatement(anyString())).thenReturn(insert, debit); + when(debit.executeUpdate()).thenReturn(1); + when(confirmation.prepareStatement(anyString())).thenReturn(select); + when(select.executeQuery()).thenReturn(committed); + when(committed.next()).thenReturn(true); + when(committed.getString(1)).thenReturn("PENDING"); + AtomicBoolean reservationClosed = new AtomicBoolean(); + org.mockito.Mockito.doAnswer(ignored -> { + reservationClosed.set(true); + return null; + }).when(reservation).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(reservation).thenAnswer(ignored -> { + assertTrue(reservationClosed.get(), "The ambiguous reservation handle must be released before confirmation"); + return confirmation; + }); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(reservation).commit(); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.reserve("purchase-ambiguous", "player", "Points", null, 10, 0, 100L)); + + verify(reservation, atLeastOnce()).close(); + verify(confirmation).prepareStatement(anyString()); + } + + @Test + void ambiguousClaimUpdateIsConfirmedBeforeRewardMayRun() throws Exception { + Fixture fixture = fixture(); + Connection claimConnection = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement claim = mock(PreparedStatement.class); + PreparedStatement select = mock(PreparedStatement.class); + ResultSet committed = mock(ResultSet.class); + when(claimConnection.prepareStatement(anyString())).thenReturn(claim); + when(confirmation.prepareStatement(anyString())).thenReturn(select); + when(select.executeQuery()).thenReturn(committed); + when(committed.next()).thenReturn(true); + when(committed.getString(1)).thenReturn("HOOK_STARTED"); + AtomicBoolean claimConnectionClosed = new AtomicBoolean(); + org.mockito.Mockito.doAnswer(ignored -> { + claimConnectionClosed.set(true); + return null; + }).when(claimConnection).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(claimConnection).thenAnswer(ignored -> { + assertTrue(claimConnectionClosed.get(), "The ambiguous claim handle must be released before confirmation"); + return confirmation; + }); + doThrow(new java.sql.SQLException("update acknowledgement lost")).when(claim).executeUpdate(); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertEquals(SharedMysqlPurchaseJournal.ClaimOutcome.CLAIMED, + journal.claimReward("purchase-ambiguous-claim", 200L)); + + verify(claimConnection, atLeastOnce()).close(); + verify(confirmation).prepareStatement(anyString()); + } + + 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.work = 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.work); + return fixture; + } + + private static ResultSet ids(String... values) throws Exception { + ResultSet rows = mock(ResultSet.class); + Boolean[] next = new Boolean[values.length + 1]; + for (int index = 0; index < values.length; index++) next[index] = Boolean.TRUE; + next[values.length] = Boolean.FALSE; + when(rows.next()).thenReturn(next[0], java.util.Arrays.copyOfRange(next, 1, next.length)); + if (values.length > 0) when(rows.getString(1)).thenReturn(values[0]); + return rows; + } + + private static ResultSet pendingRow() throws Exception { + return pendingRow("PENDING"); + } + + private static ResultSet pendingRow(String state) throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(state); + when(row.getString(2)).thenReturn("player"); + when(row.getString(3)).thenReturn("Points"); + when(row.getString(4)).thenReturn("VoteShopLimitdaily"); + when(row.getInt(5)).thenReturn(10); + return row; + } + + private static ResultSet hookStartedRow() throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn("HOOK_STARTED"); + return row; + } + + private static final class Fixture { + private MySQL table; + private com.bencodez.simpleapi.sql.mysql.MySQL sql; + private Connection work; + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 435425875..86282dc0d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.doAnswer; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; @@ -15,6 +16,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; +import java.sql.ResultSet; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -43,6 +45,14 @@ import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; class VoteShopPurchaseServiceTest { + @Test + void interruptionStillRequiresRefundWhenFallbackAlreadyRequestedCompensation() { + AtomicInteger state = new AtomicInteger(2); // COMPLETION_COMPENSATING + CountDownLatch completed = new CountDownLatch(0); + + assertTrue(VoteShopPurchaseService.compensationRequiredAfterInterruption(state, completed)); + } + @Test void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -71,17 +81,44 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw 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 schemaConnection = mock(Connection.class); + Connection pendingConnection = mock(Connection.class); + Connection cleanupConnection = mock(Connection.class); Connection debitConnection = mock(Connection.class); + Connection claimConnection = mock(Connection.class); Connection refundConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement schemaIndex = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + PreparedStatement reserve = mock(PreparedStatement.class); PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement claim = mock(PreparedStatement.class); + PreparedStatement refundSelect = mock(PreparedStatement.class); PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement refundUpdate = mock(PreparedStatement.class); + ResultSet noPendingRows = emptyRows(); + ResultSet noTerminalRows = emptyRows(); + ResultSet claimedPurchase = purchaseRow("HOOK_STARTED", "Points", null, 10); 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(debitConnection, refundConnection); - when(debitConnection.prepareStatement(anyString())).thenReturn(debit); - when(refundConnection.prepareStatement(anyString())).thenReturn(refund); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, pendingConnection, + cleanupConnection, debitConnection, claimConnection, refundConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaIndex); + when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); + when(pending.executeQuery()).thenReturn(noPendingRows); + when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); + when(debitConnection.prepareStatement(anyString())).thenReturn(reserve, debit); + when(claimConnection.prepareStatement(anyString())).thenReturn(claim); + when(claim.executeUpdate()).thenReturn(1); + when(refundConnection.prepareStatement(anyString())).thenReturn(refundSelect, refund, refundUpdate); + when(refundSelect.executeQuery()).thenReturn(claimedPurchase); when(debit.executeUpdate()).thenReturn(1); + when(refund.executeUpdate()).thenReturn(1); + when(refundUpdate.executeUpdate()).thenReturn(1); VotingPluginMain plugin = sharedMysqlPlugin(table); com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); @@ -120,8 +157,8 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw purchase.get(5, TimeUnit.SECONDS); ArgumentCaptor refundSql = ArgumentCaptor.forClass(String.class); - verify(refundConnection).prepareStatement(refundSql.capture()); - assertTrue(refundSql.getValue().contains("`Points` = `Points` + ?")); + verify(refundConnection, times(3)).prepareStatement(refundSql.capture()); + assertTrue(refundSql.getAllValues().get(1).contains("`Points` = `Points` + ?")); verify(refund).setInt(1, 10); verify(refund, times(1)).executeUpdate(); verify(entityScheduler).runAtEntityWithFallback( @@ -174,6 +211,38 @@ void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), null); } + @Test + void sharedMysqlDebitClosesItsConnectionBeforeRefreshingTheCache() 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); + UserData data = mock(UserData.class); + UserDataCache cache = mock(UserDataCache.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); + VotingPluginUser user = purchaseUser(); + when(user.isCached()).thenReturn(false, true); + when(user.getUserData()).thenReturn(data); + when(user.getCache()).thenReturn(cache); + when(data.getInt("Points", UserDataFetchMode.NO_CACHE)).thenReturn(90); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(sharedMysqlPlugin(table), null).debitSharedMysql(user, item)); + + InOrder closeBeforeRefresh = inOrder(connection, data); + closeBeforeRefresh.verify(connection).close(); + closeBeforeRefresh.verify(data).getInt("Points", UserDataFetchMode.NO_CACHE); + } + @Test void sharedMysqlFailureReleasesDebitConnectionBeforeClassifyingTheLimit() throws Exception { MySQL table = mock(MySQL.class); @@ -235,14 +304,42 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti 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); + Connection schemaConnection = mock(Connection.class); + Connection pendingConnection = mock(Connection.class); + Connection cleanupConnection = mock(Connection.class); + Connection reserveConnection = mock(Connection.class); + Connection claimConnection = mock(Connection.class); + Connection completeConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement schemaIndex = mock(PreparedStatement.class); + PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement cleanupSelect = mock(PreparedStatement.class); + PreparedStatement cleanupDelete = mock(PreparedStatement.class); + PreparedStatement reserve = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement claim = mock(PreparedStatement.class); + PreparedStatement completeSelect = mock(PreparedStatement.class); + PreparedStatement completeUpdate = mock(PreparedStatement.class); + ResultSet noPendingRows = emptyRows(); + ResultSet noTerminalRows = emptyRows(); + ResultSet hookStartedPurchase = purchaseRow("HOOK_STARTED", "Points", null, 10); 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); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, pendingConnection, + cleanupConnection, reserveConnection, claimConnection, completeConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaIndex); + when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); + when(pending.executeQuery()).thenReturn(noPendingRows); + when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); + when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); + when(reserveConnection.prepareStatement(anyString())).thenReturn(reserve, debit); + when(debit.executeUpdate()).thenReturn(1); + when(claimConnection.prepareStatement(anyString())).thenReturn(claim); + when(claim.executeUpdate()).thenReturn(1); + when(completeConnection.prepareStatement(anyString())).thenReturn(completeSelect, completeUpdate); + when(completeSelect.executeQuery()).thenReturn(hookStartedPurchase); + when(completeUpdate.executeUpdate()).thenReturn(1); VotingPluginMain plugin = sharedMysqlPlugin(table); when(plugin.isEnabled()).thenReturn(true); RewardHandler rewardHandler = mock(RewardHandler.class); @@ -271,6 +368,10 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti when(item.getPurchaseMessage()).thenReturn(""); VotingPluginUser user = purchaseUser(); org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); FileConfiguration oldShopData = mock(FileConfiguration.class); FileConfiguration reloadedShopData = mock(FileConfiguration.class); when(plugin.getShopFile().getData()).thenReturn(oldShopData, reloadedShopData); @@ -290,8 +391,15 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti ArgumentCaptor entityCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback(any(), entityCallback.capture(), any(Runnable.class)); + InOrder claimBeforeEntityWork = inOrder(claimConnection, entityScheduler); + claimBeforeEntityWork.verify(claimConnection).prepareStatement(anyString()); + claimBeforeEntityWork.verify(entityScheduler).runAtEntityWithFallback(any(), any(), any(Runnable.class)); entityCallback.getValue().accept(null); + verify(completeConnection, never()).prepareStatement(anyString()); purchase.get(5, TimeUnit.SECONDS); + ArgumentCaptor scheduledWork = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor, times(2)).execute(scheduledWork.capture()); + scheduledWork.getAllValues().get(1).run(); worker.shutdownNow(); } @@ -352,6 +460,24 @@ private static PreparedStatement conditionalDebitStatement(AtomicInteger balance return statement; } + private static ResultSet emptyRows() throws Exception { + ResultSet rows = mock(ResultSet.class); + when(rows.next()).thenReturn(false); + return rows; + } + + private static ResultSet purchaseRow(String state, String pointsColumn, String limitColumn, int cost) + throws Exception { + ResultSet row = mock(ResultSet.class); + when(row.next()).thenReturn(true); + when(row.getString(1)).thenReturn(state); + when(row.getString(2)).thenReturn("00000000-0000-0000-0000-000000000001"); + when(row.getString(3)).thenReturn(pointsColumn); + when(row.getString(4)).thenReturn(limitColumn); + when(row.getInt(5)).thenReturn(cost); + return row; + } + private static VotingPluginMain sharedMysqlPlugin(MySQL table) { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); From 4d3f40485738d2ea207a040e0f8fafdcb37fb117 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 06:01:27 -0600 Subject: [PATCH 18/74] Recover shared transfers and bound journal names --- .../votingplugin/VotingPluginMain.java | 1 + .../user/SharedMysqlPointMutator.java | 25 +++++++++++++ .../user/SharedPointTransferJournal.java | 35 ++++++++++++++++++- .../votingplugin/user/UserManager.java | 7 ++++ .../service/SharedMysqlPurchaseJournal.java | 35 ++++++++++++++++++- .../user/SharedMysqlPointMutatorTest.java | 18 ++++++++++ .../user/SharedPointTransferJournalTest.java | 16 +++++++++ .../SharedMysqlPurchaseJournalTest.java | 15 ++++++++ 8 files changed, 150 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 06cf263bd..367d85671 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -605,6 +605,7 @@ public void onPostLoad() { voteTester = new VoteTester(plugin); loadVoteTimer(); + getVotingPluginUserManager().startSharedPointTransferRecovery(); if (bungeeSettings.isUseBungeecoord()) { loadBungeeHandler(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 114857aaf..0d9e1eaa6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -4,6 +4,7 @@ import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.function.IntFunction; import com.bencodez.advancedcore.api.user.UserStorage; @@ -20,10 +21,34 @@ final class SharedMysqlPointMutator { } boolean applies() { + return usesSharedMysqlPoints(plugin); + } + + static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints(); } + /** + * Recovers a bounded batch immediately and periodically. The executor belongs + * to the plugin lifecycle, so no independent task survives shutdown. + */ + static void scheduleTransferRecovery(VotingPluginMain plugin) { + plugin.getTimer().execute(() -> recoverTransfers(plugin)); + plugin.getTimer().scheduleWithFixedDelay(() -> recoverTransfers(plugin), 1L, 1L, TimeUnit.MINUTES); + } + + private static void recoverTransfers(VotingPluginMain plugin) { + if (!usesSharedMysqlPoints(plugin)) return; + try { + SharedPointTransferJournal.forTable(plugin.getMysql()).recoverAndCleanup(System.currentTimeMillis()); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to recover shared MySQL point transfers: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } + } + int add(VotingPluginUser user, int amount, boolean async) { if (async) { int predictedTotal = user.getPoints() + amount; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index d6908b0d7..d290123da 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -6,6 +6,9 @@ import java.sql.SQLException; 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.util.ArrayList; import java.util.HashSet; import java.util.Iterator; @@ -33,6 +36,11 @@ final class SharedPointTransferJournal { 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 = "_PointTransfers"; + private static final String HASHED_TABLE_PREFIX = "vp_pt_"; + private static final int HASHED_TABLE_HEX_LENGTH = 32; private final MySQL table; private final String journalTable; @@ -51,10 +59,35 @@ final class SharedPointTransferJournal { private SharedPointTransferJournal(MySQL table, boolean initializeSchema) throws SQLException { this.table = table; - this.journalTable = table.getTableName() + "_PointTransfers"; + this.journalTable = journalTableName(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) { + String legacyName = sourceTable + JOURNAL_SUFFIX; + if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName; + return HASHED_TABLE_PREFIX + hash(sourceTable + '\0' + JOURNAL_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); + } + } + /** Returns a journal handle after ensuring the schema once per live MySQL table handle. */ static SharedPointTransferJournal forTable(MySQL table) throws SQLException { synchronized (INITIALIZED) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java index 6b1a166f4..386da8032 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java @@ -23,6 +23,7 @@ */ public class UserManager { private VotingPluginMain plugin; + private boolean sharedPointTransferRecoveryScheduled; /** * Constructs a new user manager. @@ -32,6 +33,12 @@ public UserManager(VotingPluginMain plugin) { this.plugin = plugin; } + /** Starts the durable shared-point transfer recovery exactly once per plugin lifecycle. */ + public synchronized void startSharedPointTransferRecovery() { + if (sharedPointTransferRecoveryScheduled || !SharedMysqlPointMutator.usesSharedMysqlPoints(plugin)) return; + sharedPointTransferRecoveryScheduled = true; + SharedMysqlPointMutator.scheduleTransferRecovery(plugin); + } /** * Adds caching keys to the user data manager. */ 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 index 387a8ccb3..c38ef911f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -2,6 +2,9 @@ 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; @@ -34,6 +37,11 @@ final class SharedMysqlPurchaseJournal { 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 HASHED_TABLE_PREFIX = "vp_vsp_"; + private static final int HASHED_TABLE_HEX_LENGTH = 32; private static final ReferenceQueue INITIALIZED_QUEUE = new ReferenceQueue<>(); private static final Set INITIALIZED = new HashSet<>(); @@ -43,10 +51,35 @@ final class SharedMysqlPurchaseJournal { SharedMysqlPurchaseJournal(MySQL table, boolean initializeSchema) throws SQLException { this.table = table; - journalTable = table.getTableName() + "_VoteShopPurchases"; + journalTable = journalTableName(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) { + String legacyName = sourceTable + JOURNAL_SUFFIX; + if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName; + return HASHED_TABLE_PREFIX + hash(sourceTable + '\0' + JOURNAL_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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 40d17f291..8441873d3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -14,6 +14,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -24,6 +25,23 @@ import com.bencodez.votingplugin.VotingPluginMain; class SharedMysqlPointMutatorTest { + @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 removeReportsARejectedConditionalDebit() throws Exception { MySQL table = mock(MySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java index cff6bb545..2598d3f63 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -1,6 +1,8 @@ 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; @@ -20,6 +22,20 @@ 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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index bdf91ece9..560907e52 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -2,6 +2,7 @@ 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.assertTrue; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; @@ -20,6 +21,20 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; class SharedMysqlPurchaseJournalTest { + @Test + void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() { + String source = "u".repeat(80); + String journalTable = SharedMysqlPurchaseJournal.journalTableName(source); + + assertEquals(journalTable, SharedMysqlPurchaseJournal.journalTableName(source)); + assertTrue(journalTable.getBytes(java.nio.charset.StandardCharsets.UTF_8).length <= 63); + assertTrue(journalTable.matches("vp_vsp_[0-9a-f]{32}")); + assertNotEquals(journalTable, SharedMysqlPurchaseJournal.journalTableName(source + "x")); + assertTrue(SharedMysqlPurchaseJournal.journalTableName("é".repeat(30)).matches("vp_vsp_[0-9a-f]{32}")); + assertEquals("VotingPlugin_Users_VoteShopPurchases", + SharedMysqlPurchaseJournal.journalTableName("VotingPlugin_Users")); + } + @Test void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { Fixture fixture = fixture(); From 8d07e5a8d64522fa881c7053c6a9b7d7a9109988 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:48:09 -0600 Subject: [PATCH 19/74] Make shared MySQL purchases failure-safe --- .../votingplugin/VotingPluginMain.java | 3 + .../votingplugin/commands/CommandLoader.java | 4 +- .../rewards/builtin/RewardPoints.java | 2 +- .../user/SharedMysqlPointMutator.java | 28 ++- .../votingplugin/user/VotingPluginUser.java | 28 ++- .../voteshop/VoteShopManager.java | 6 + .../service/SharedMysqlPurchaseJournal.java | 89 +++++-- .../service/VoteShopPurchaseService.java | 227 +++++++++++++----- .../user/SharedMysqlPointMutatorTest.java | 51 +++- .../VotingPluginUserPointSchedulingTest.java | 69 +++++- .../SharedMysqlPurchaseJournalTest.java | 55 ++++- .../service/VoteShopPurchaseServiceTest.java | 129 ++++++++-- 12 files changed, 573 insertions(+), 118 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java index 367d85671..0a4a12464 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/VotingPluginMain.java @@ -1517,6 +1517,9 @@ private void reloadPlugin(boolean userStorage, boolean reconcileHostedControl) { updateAdvancedCoreHook(); reloadAdvancedCore(userStorage); + // PerServerPoints can switch shared point storage on during a live reload. + // Re-evaluate after storage has reloaded; UserManager keeps this lifecycle task unique. + getVotingPluginUserManager().startSharedPointTransferRecovery(); if (bungeeSettings.isUseBungeecoord()) { BackendProxyHandler handler = getBackendProxyHandler(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 4b666718e..fbcbf64a2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -444,7 +444,7 @@ public void executeAll(CommandSender sender, String[] args) { UUID uuid = UUID.fromString(uuidStr); VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.addPoints(num); + user.addPointsStorageAware(num); if (user.isOnline()) { user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), "amount", args[3]); @@ -460,7 +460,7 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.cache(); int newTotal = 0; - newTotal = user.addPoints(Integer.parseInt(args[3])); + newTotal = user.addPointsStorageAware(Integer.parseInt(args[3])); if (user.isOnline()) { user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), "amount", args[3]); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index 820e8e204..803bca4cf 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -46,7 +46,7 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - String result = "" + vpUser.addPoints(num); + String result = "" + vpUser.addPointsStorageAware(num); plugin.debug("Setting points to " + result); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 0d9e1eaa6..5fa10f854 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -8,8 +8,11 @@ import java.util.function.IntFunction; import com.bencodez.advancedcore.api.user.UserStorage; +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.mysql.DbType; +import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.votingplugin.VotingPluginMain; /** Performs point writes that must remain atomic across shared MySQL servers. */ @@ -51,7 +54,7 @@ private static void recoverTransfers(VotingPluginMain plugin) { int add(VotingPluginUser user, int amount, boolean async) { if (async) { - int predictedTotal = user.getPoints() + amount; + int predictedTotal = cachedPoints(user) + amount; run(() -> update(user, amount, false), true); // The mutation has not happened yet, so the historical asynchronous API // returns its predicted post-event total without blocking for storage. @@ -74,13 +77,34 @@ boolean remove(VotingPluginUser user, int amount) { boolean remove(VotingPluginUser user, int amount, boolean async) { if (!async) return remove(user, amount); - boolean predictedSuccess = user.getPoints() >= amount; + boolean predictedSuccess = cachedPoints(user) >= amount; run(() -> update(user, -amount, true), true); // Preserve the historical asynchronous API contract: the caller receives // the cached prediction while the conditional database debit runs later. return predictedSuccess; } + private int cachedPoints(VotingPluginUser user) { + String path = user.getPointsPath(); + UserDataCache cache = user.getCache(); + if (cache != null) { + synchronized (cache) { + DataValue value = cache.getCache() == null ? null : cache.getCache().get(path); + if (value != null) { + if (value.isInt()) return value.getInt(); + if (value.isString()) { + try { + return Integer.parseInt(value.getString()); + } catch (NumberFormatException ignored) { + // Fall through to the temporary cache/default below. + } + } + } + } + } + return user.getUserData().getInt(path, UserDataFetchMode.TEMP_ONLY); + } + boolean transfer(VotingPluginUser source, VotingPluginUser target, int amount) { return transfer(source, target, amount, amount); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 42afcea45..76fec6814 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -178,15 +178,19 @@ public void addOfflineVote(String voteSiteName) { /** * Adds points to the user based on the configuration. */ - public void addPoints() { - int points = plugin.getConfigFile().getPointsOnVote(); - if (points != 0) { - addPoints(points); - } + public void addPoints() { + int points = plugin.getConfigFile().getPointsOnVote(); + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + boolean sharedMysql = sharedPoints.applies(); + if (points != 0) { + // Vote processing runs on the server lane. Shared MySQL arithmetic must + // use the lifecycle persistence executor instead of blocking a tick on + // connection acquisition and the committed-balance read. + addPoints(points, sharedMysql); + } if (plugin.getConfigFile().getLimitVotePoints() > 0) { - SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) { - sharedPoints.cap(this, plugin.getConfigFile().getLimitVotePoints(), false); + if (sharedMysql) { + sharedPoints.cap(this, plugin.getConfigFile().getLimitVotePoints(), true); } else if (getPoints() > plugin.getConfigFile().getLimitVotePoints()) { setPoints(plugin.getConfigFile().getLimitVotePoints()); } @@ -225,6 +229,14 @@ public synchronized int addPoints(int value, boolean async) { setPoints(newTotal, async); return newTotal; } + + /** + * Keeps ordinary storage writes synchronous while moving shared-MySQL atomic + * arithmetic onto the persistence executor. + */ + public int addPointsStorageAware(int value) { + return addPoints(value, new SharedMysqlPointMutator(plugin).applies()); + } /** * Adds one to the total votes. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java index 0be8cad6b..46ff729f2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java @@ -116,4 +116,10 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, Consumer completion) { purchaseService.purchase(player, user, item, completion); } + + /** @deprecated use the callback overload for the final shared-storage result. */ + @Deprecated + public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) { + return purchaseService.purchase(player, user, item); + } } 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 index c38ef911f..d8317786d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -33,6 +33,7 @@ final class SharedMysqlPurchaseJournal { private static final String HOOK_STARTED = "HOOK_STARTED"; 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; @@ -104,11 +105,12 @@ private static void expungeInitialized() { /** Atomically records a pending purchase and conditionally charges it. */ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limitColumn, int cost, int limit, - long now) throws SQLException { + 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("state") + ", " + qi("created_at") - + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; + + qi("limit_value") + ", " + qi("limit_generation") + ", " + + qi("limit_generation_expires_at") + ", " + 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(" - ?"); @@ -132,8 +134,12 @@ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limi insertStatement.setInt(5, cost); if (limitColumn == null) insertStatement.setNull(6, java.sql.Types.INTEGER); else insertStatement.setInt(6, limit); - insertStatement.setString(7, PENDING); - insertStatement.setLong(8, now); + 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); + insertStatement.setString(9, PENDING); + insertStatement.setLong(10, now); insertStatement.executeUpdate(); debitStatement.setInt(1, cost); @@ -210,27 +216,34 @@ ClaimOutcome claimReward(String purchaseId, long startedAt) throws SQLException } void complete(String purchaseId) throws SQLException { - setTerminal(purchaseId, COMPLETED, null); + setTerminal(purchaseId, COMPLETED, 0L); } /** Refunds only a debit whose reward hook has not started. */ boolean refundPending(String purchaseId) throws SQLException { - return setTerminal(purchaseId, REFUNDED, PENDING); + return refundPending(purchaseId, System.currentTimeMillis()); + } + + boolean refundPending(String purchaseId, long now) throws SQLException { + return setTerminal(purchaseId, REFUNDED, now, PENDING); } /** - * Compensates a claimed purchase only when the local scheduler guard proves - * that its reward callback can no longer start. This is never used by stale - * recovery, which must leave arbitrary HOOK_STARTED work for reconciliation. + * Compensates a pending or claimed purchase only when the local scheduler + * guard proves that its reward callback cannot run. This is never used by + * stale recovery, which must leave arbitrary HOOK_STARTED work for + * reconciliation. */ - boolean refundClaimedBeforeReward(String purchaseId) throws SQLException { - return setTerminal(purchaseId, REFUNDED, HOOK_STARTED); + boolean refundUnstartedReward(String purchaseId) throws SQLException { + return setTerminal(purchaseId, REFUNDED, System.currentTimeMillis(), PENDING, HOOK_STARTED); } - private boolean setTerminal(String purchaseId, String terminalState, String refundableState) throws SQLException { + private boolean setTerminal(String purchaseId, String terminalState, long now, String... refundableStates) + throws SQLException { boolean refund = REFUNDED.equals(terminalState); String select = "SELECT " + qi("state") + ", " + qi("player_uuid") + ", " + qi("points_column") - + ", " + qi("limit_column") + ", " + qi("cost") + " FROM " + qiJournal() + " WHERE " + + ", " + qi("limit_column") + ", " + qi("cost") + ", " + qi("limit_generation") + ", " + + qi("limit_generation_expires_at") + " FROM " + qiJournal() + " WHERE " + qi("purchase_id") + " = ? FOR UPDATE"; try (Connection connection = connection()) { connection.setAutoCommit(false); @@ -246,7 +259,7 @@ private boolean setTerminal(String purchaseId, String terminalState, String refu rollback(connection); return terminalState.equals(state); } - if (refund && !refundableState.equals(state)) { + if (refund && !isRefundableState(state, refundableStates)) { rollback(connection); return false; } @@ -258,7 +271,12 @@ private boolean setTerminal(String purchaseId, String terminalState, String refu String pointsColumn = result.getString(3); String limitColumn = result.getString(4); int cost = result.getInt(5); - if (refund) refund(connection, uuid, pointsColumn, limitColumn, cost); + String limitGeneration = result.getString(6); + long limitGenerationExpiresAt = result.getLong(7); + if (refund) { + refund(connection, uuid, pointsColumn, limitColumn, cost, limitGeneration, + limitGenerationExpiresAt, now); + } } } String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("purchase_id") @@ -278,14 +296,24 @@ private boolean setTerminal(String purchaseId, String terminalState, String refu } } - private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost) - throws SQLException { + 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, + String limitGeneration, long limitGenerationExpiresAt, long now) 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 (limitColumn != null) { + // Once an item has crossed its recorded reset boundary, this is an old + // generation. Restore the charged points but never decrement a count that + // may belong to a new daily/weekly/monthly window. + if (limitColumn != null && canRefundLimit(limitGeneration, limitGenerationExpiresAt, now)) { refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn)) .append(", 0) - 1, 0)"); } @@ -297,6 +325,13 @@ private void refund(Connection connection, String uuid, String pointsColumn, Str } } + private static boolean canRefundLimit(String generation, long expiresAt, long now) { + if (NO_LIMIT_RESET_GENERATION.equals(generation)) return true; + // A row created before generation metadata existed cannot safely identify the + // current reset window, so preserve the newer count conservatively. + return generation != null && expiresAt > 0L && now < expiresAt; + } + void recoverAndCleanup(long now) throws SQLException { long cutoff = now - PENDING_RECOVERY_AGE_MILLIS; String select = "SELECT " + qi("purchase_id") + " FROM " + qiJournal() + " WHERE " + qi("state") @@ -310,7 +345,7 @@ void recoverAndCleanup(long now) throws SQLException { while (result.next()) pending.add(result.getString(1)); } } - for (String purchaseId : pending) refundPending(purchaseId); + for (String purchaseId : pending) refundPending(purchaseId, now); cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS); } @@ -343,11 +378,14 @@ 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("state") + + qi("cost") + " INT NOT NULL, " + qi("limit_value") + " INT NULL, " + qi("limit_generation") + + " VARCHAR(96) NULL, " + qi("limit_generation_expires_at") + " 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"); 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") + ");"; @@ -359,6 +397,15 @@ private void ensureSchema() throws SQLException { } } + 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 { return table.getMysql().getConnectionManager().getConnection(); } 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 faba31fc7..f6f1b3add 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 @@ -3,11 +3,14 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.time.Duration; +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.CountDownLatch; -import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -17,6 +20,7 @@ import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.RewardOptions; +import com.bencodez.advancedcore.api.time.TimeCalculation; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; @@ -158,7 +162,10 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, plugin.getTimer().execute(() -> { SharedPurchaseDebit debit; synchronized (purchaseLock(user.getUUID())) { - debit = reserveSharedMysqlPurchase(user, item); + // 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) { plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit.result()), player); @@ -168,80 +175,112 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, }); } + /** + * Compatibility entry point for integrations compiled against the synchronous + * API. For shared MySQL, success means the durable asynchronous purchase was + * accepted; 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 (!usesSharedMysqlPoints()) return purchaseLocal(player, user, item); + VoteShopPurchaseResult validation = validateStaticPurchase(player, item); + if (validation != VoteShopPurchaseResult.SUCCESS) return validation; + purchase(player, user, item, ignored -> { }); + return VoteShopPurchaseResult.SUCCESS; + } + private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item, HashMap placeholders, FileConfiguration shopData, Consumer completion, SharedPurchaseDebit debit) { - CountDownLatch completed = new CountDownLatch(1); AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); + Runnable compensateBeforeClaim = () -> { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return; + plugin.getTimer().execute(() -> refundSharedMysqlDebit(user, debit, true)); + }; try { - /* Claim on the persistence worker before entering the entity scheduler. - * A JDBC pool wait or database lock must never block a Bukkit/Folia entity - * lane; the scheduled callback below performs reward/UI work only. */ - SharedMysqlPurchaseJournal.ClaimOutcome claim = claimSharedMysqlPurchase(debit); - if (claim == SharedMysqlPurchaseJournal.ClaimOutcome.NOT_CLAIMED) { - refundSharedMysqlDebit(user, debit, false); - return; - } - if (claim == SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE) { - plugin.getLogger().severe("Shared MySQL vote shop purchase " + debit.purchaseId() - + " has an indeterminate reward claim; retaining it for reconciliation"); - return; + /* + * 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. + */ + CompletableFuture gate = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(player, ignored -> { + if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; + claimSharedMysqlPurchaseAsync(debit).whenComplete((claim, failure) -> { + if (failure != null || claim == SharedMysqlPurchaseJournal.ClaimOutcome.NOT_CLAIMED) { + if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) { + plugin.getTimer().execute(() -> refundSharedMysqlDebit(user, debit, true)); + } + return; + } + if (claim == SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE) { + state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); + plugin.getLogger().severe("Shared MySQL vote shop purchase " + debit.purchaseId() + + " has an indeterminate reward claim; retaining it for reconciliation"); + return; + } + state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); + scheduleClaimedReward(player, user, item, placeholders, shopData, completion, debit); + }); + }, compensateBeforeClaim); + gate.whenComplete((result, failure) -> { + if (failure != null || result != EntityTaskResult.SUCCESS) { + compensateBeforeClaim.run(); + } + }); + } catch (RuntimeException schedulingFailure) { + compensateBeforeClaim.run(); + plugin.debug(schedulingFailure); + } + } + + 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)) { + plugin.getTimer().execute(() -> refundSharedMysqlDebit(user, debit, true)); } - CompletableFuture scheduled = plugin.getBukkitScheduler().getFoliaLib().getImpl() + }; + try { + CompletableFuture reward = plugin.getBukkitScheduler().getFoliaLib().getImpl() .runAtEntityWithFallback(player, ignored -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; try { completePurchase(player, user, item, placeholders, shopData); - /* - * The entity callback owns only reward/UI work. Queue the terminal - * journal update back to the persistence executor after the reward - * completes, so a JDBC pool wait cannot stall an entity lane. - */ plugin.getTimer().execute(() -> settleSharedMysqlPurchase(player, completion, debit)); + } catch (RuntimeException | Error rewardFailure) { + logClaimedRewardSchedulingFailure(debit); + throw rewardFailure; } finally { - state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); - completed.countDown(); + state.set(COMPLETION_FINISHED); } - }, () -> requestCompensation(state, completed)); - scheduled.whenComplete((result, failure) -> { - if (failure != null || result != EntityTaskResult.SUCCESS) requestCompensation(state, completed); + }, rejectBeforeStart); + reward.whenComplete((result, failure) -> { + if (failure != null || result != EntityTaskResult.SUCCESS) rejectBeforeStart.run(); }); - while (!completed.await(100, TimeUnit.MILLISECONDS)) { - if (!plugin.isEnabled()) requestCompensation(state, completed); - } - if (state.get() == COMPLETION_COMPENSATING) refundSharedMysqlDebit(user, debit, true); - } catch (InterruptedException interrupted) { - Thread.currentThread().interrupt(); - if (compensationRequiredAfterInterruption(state, completed)) refundSharedMysqlDebit(user, debit, true); } catch (RuntimeException schedulingFailure) { - requestCompensation(state, completed); - if (state.get() == COMPLETION_COMPENSATING) refundSharedMysqlDebit(user, debit, true); + 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"); + } + private void settleSharedMysqlPurchase(Player player, Consumer completion, SharedPurchaseDebit debit) { completeSharedMysqlPurchase(debit); plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(VoteShopPurchaseResult.SUCCESS), player); } - private static boolean requestCompensation(AtomicInteger state, CountDownLatch completed) { - if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return false; - completed.countDown(); - return true; - } - - /** - * An entity scheduler fallback can request compensation just before the - * persistence worker is interrupted. The latter still owns the debit and - * must perform the refund even though it did not win the state transition. - */ - static boolean compensationRequiredAfterInterruption(AtomicInteger state, CountDownLatch completed) { - requestCompensation(state, completed); - return state.get() == COMPLETION_COMPENSATING; - } - private HashMap purchasePlaceholders(VoteShopItem item) { HashMap placeholders = new HashMap(); placeholders.put("identifier", item.getIdentifierName()); @@ -357,7 +396,8 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item return sharedMysqlFailure(user, item, limitColumn); } - private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, VoteShopItem item) { + 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; @@ -376,7 +416,7 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo journal.recoverAndCleanup(System.currentTimeMillis()); String purchaseId = UUID.randomUUID().toString(); if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), - System.currentTimeMillis())) { + 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); @@ -403,6 +443,18 @@ private SharedMysqlPurchaseJournal.ClaimOutcome claimSharedMysqlPurchase(SharedP } } + 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()); @@ -419,7 +471,7 @@ private void refundSharedMysqlDebit(VotingPluginUser user, SharedPurchaseDebit d boolean schedulerProvesRewardCannotRun) { try { boolean refunded = schedulerProvesRewardCannotRun - ? debit.journal().refundClaimedBeforeReward(debit.purchaseId()) + ? debit.journal().refundUnstartedReward(debit.purchaseId()) : debit.journal().refundPending(debit.purchaseId()); if (refunded) { // refundPending() closes its transaction handle before any NO_CACHE @@ -450,10 +502,73 @@ private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, St } } - private record SharedPurchaseDebit(VoteShopPurchaseResult result, SharedMysqlPurchaseJournal journal, + private LimitGeneration limitGeneration(VoteShopItem item, long nowMillis) { + if (item.getLimit() <= 0) return LimitGeneration.NONE; + String identifier = item.getIdentifier(); + boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier); + boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier); + boolean monthly = plugin.getShopFile().getVoteShopResetMonthly(identifier); + return limitGeneration(plugin.getTimeChecker().getTime(), nowMillis, daily, weekly, monthly, + plugin.getOptions().getTimeWeekOffSet(), configuredTimeZone(), plugin.getOptions().getTimeHourOffSet()); + } + + private ZoneId configuredTimeZone() { + String configured = plugin.getOptions().getTimeZone(); + if (configured == null || configured.isEmpty()) return ZoneId.systemDefault(); + try { + return ZoneId.of(configured); + } catch (RuntimeException invalidZone) { + return ZoneId.systemDefault(); + } + } + + static LimitGeneration limitGeneration(LocalDateTime current, long nowMillis, boolean daily, boolean weekly, + boolean monthly, int weekOffset) { + return limitGeneration(current, nowMillis, daily, weekly, monthly, weekOffset, ZoneId.systemDefault(), 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 = TimeCalculation.weekNumber(current, weekOffset, Locale.getDefault()); + while (TimeCalculation.weekNumber(weekBoundary, weekOffset, Locale.getDefault()) == week) { + weekBoundary = weekBoundary.plusDays(1); + } + if (next == null || weekBoundary.isBefore(next)) next = weekBoundary; + if (generation.length() > 0) generation.append('|'); + LocalDateTime weekTime = current.plusDays(weekOffset); + WeekFields fields = WeekFields.of(Locale.getDefault()); + generation.append("W:").append(weekTime.get(fields.weekBasedYear())).append('-') + .append(weekTime.get(fields.weekOfWeekBasedYear())); + } + 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); + } + + 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)]; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 8441873d3..e45997fd2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -9,6 +9,7 @@ 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; @@ -21,7 +22,9 @@ 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.votingplugin.VotingPluginMain; class SharedMysqlPointMutatorTest { @@ -42,6 +45,26 @@ void userManagerSchedulesOneBoundedSharedTransferRecoveryPerLifecycle() { org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); } + @Test + void userManagerSchedulesRecoveryOnceWhenReloadEnablesSharedPoints() { + 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, false); + when(plugin.getTimer()).thenReturn(persistence); + + UserManager manager = new UserManager(plugin); + manager.startSharedPointTransferRecovery(); // Startup with per-server points. + verifyNoInteractions(persistence); + manager.startSharedPointTransferRecovery(); // Reload switches to shared points. + 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 removeReportsARejectedConditionalDebit() throws Exception { MySQL table = mock(MySQL.class); @@ -81,7 +104,9 @@ void asynchronousRemoveDoesNotAcquireJdbcOnTheCallerThread() throws Exception { ScheduledExecutorService persistence = mock(ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(persistence); VotingPluginUser user = mock(VotingPluginUser.class); - when(user.getPoints()).thenReturn(20); + 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"); @@ -89,12 +114,36 @@ void asynchronousRemoveDoesNotAcquireJdbcOnTheCallerThread() throws Exception { 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(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(2)).getCache(); + verify(user, never()).getPoints(); + verify(persistence).execute(any(Runnable.class)); + } + @Test void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exception { MySQL table = mock(MySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 271e156a4..1c1d60cfb 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -29,7 +29,6 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; -import org.mockito.InOrder; import org.mockito.MockedStatic; import com.bencodez.advancedcore.api.user.UserStorage; @@ -42,6 +41,61 @@ import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; class VotingPluginUserPointSchedulingTest { + @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 sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal() throws Exception { PointFixture fixture = pointFixture(); @@ -72,7 +126,9 @@ void sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal( @Test void sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { PointFixture fixture = pointFixture(); - doReturn(10).when(fixture.user).getPoints(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(10); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { PluginManager pluginManager = mock(PluginManager.class); @@ -88,6 +144,8 @@ void sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() verify(fixture.persistence).execute(any(Runnable.class)); verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(data).getInt("Points", UserDataFetchMode.TEMP_ONLY); + verify(fixture.user, never()).getPoints(); } @Test @@ -104,7 +162,9 @@ void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { @Test void sharedAsyncRemoveKeepsJdbcOffTheCallerThread() throws Exception { PointFixture fixture = pointFixture(); - doReturn(20).when(fixture.user).getPoints(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(20); when(fixture.statement.executeUpdate()).thenReturn(1); assertTrue(fixture.user.removePoints(10, true)); @@ -112,6 +172,8 @@ void sharedAsyncRemoveKeepsJdbcOffTheCallerThread() throws Exception { ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(data).getInt("Points", UserDataFetchMode.TEMP_ONLY); + verify(fixture.user, never()).getPoints(); persistenceWork.getValue().run(); verify(fixture.sql.getConnectionManager()).getConnection(); verify(fixture.statement).executeUpdate(); @@ -329,6 +391,7 @@ private static PointFixture pointFixture() throws Exception { doReturn("Points").when(fixture.user).getPointsPath(); doReturn(fixture.player).when(fixture.user).getPlayer(); doReturn(false).when(fixture.user).isCached(); + doReturn(null).when(fixture.user).getCache(); return fixture; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 560907e52..3579aa01d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -44,9 +44,11 @@ void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { when(fixture.work.prepareStatement(anyString())).thenReturn(insert, debit); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); - assertTrue(journal.reserve("purchase-1", "player", "Points", "VoteShopLimitdaily", 10, 1, 100L)); + assertTrue(journal.reserve("purchase-1", "player", "Points", "VoteShopLimitdaily", 10, 1, + SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION, 0L, 100L)); - verify(insert).setString(7, "PENDING"); + verify(insert).setString(7, SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); + verify(insert).setString(9, "PENDING"); verify(debit).setInt(1, 10); verify(fixture.work).commit(); } @@ -97,6 +99,50 @@ void hookStartedPurchaseIsNeverRefundedByCompensation() throws Exception { assertFalse(journal.refundPending("claimed-purchase")); } + @Test + void staleRefundRestoresPointsWithoutDecrementingANewerLimitGeneration() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + when(pending.getString(6)).thenReturn("D:2026-09-08"); + when(pending.getLong(7)).thenReturn(100L); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); + when(select.executeQuery()).thenReturn(pending); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("old-generation", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("`Points` = `Points` + ?")); + assertFalse(sql.getAllValues().get(1).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void refundStillReleasesALimitThatHasNoConfiguredReset() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + when(pending.getString(6)).thenReturn(SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); + when(select.executeQuery()).thenReturn(pending); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("unbounded-generation", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("`VoteShopLimitdaily` = GREATEST")); + } + @Test void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { Fixture fixture = fixture(); @@ -110,7 +156,7 @@ void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { when(terminal.executeUpdate()).thenReturn(1); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); - assertTrue(journal.refundClaimedBeforeReward("scheduler-rejected")); + assertTrue(journal.refundUnstartedReward("scheduler-rejected")); verify(terminal).setString(1, "REFUNDED"); } @@ -142,7 +188,8 @@ void ambiguousReservationCommitIsConfirmedAfterItsConnectionIsReleased() throws doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(reservation).commit(); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); - assertTrue(journal.reserve("purchase-ambiguous", "player", "Points", null, 10, 0, 100L)); + assertTrue(journal.reserve("purchase-ambiguous", "player", "Points", null, 10, 0, + null, 0L, 100L)); verify(reservation, atLeastOnce()).close(); verify(confirmation).prepareStatement(anyString()); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 86282dc0d..49b913e00 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -1,6 +1,8 @@ package com.bencodez.votingplugin.voteshop.service; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -17,6 +19,8 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; @@ -46,11 +50,26 @@ class VoteShopPurchaseServiceTest { @Test - void interruptionStillRequiresRefundWhenFallbackAlreadyRequestedCompensation() { - AtomicInteger state = new AtomicInteger(2); // COMPLETION_COMPENSATING - CountDownLatch completed = new CountDownLatch(0); + void retainsSynchronousPurchaseDescriptorsForBinaryCompatibility() throws Exception { + assertEquals(VoteShopPurchaseResult.class, VoteShopPurchaseService.class + .getMethod("purchase", org.bukkit.entity.Player.class, VotingPluginUser.class, VoteShopItem.class) + .getReturnType()); + assertEquals(VoteShopPurchaseResult.class, com.bencodez.votingplugin.voteshop.VoteShopManager.class + .getMethod("purchase", org.bukkit.entity.Player.class, VotingPluginUser.class, VoteShopItem.class) + .getReturnType()); + } - assertTrue(VoteShopPurchaseService.compensationRequiredAfterInterruption(state, completed)); + @Test + void limitGenerationUsesTheEarliestConfiguredResetBoundary() { + LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); + long now = current.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + VoteShopPurchaseService.LimitGeneration generation = VoteShopPurchaseService.limitGeneration( + current, now, true, true, true, 0); + + assertTrue(generation.value().contains("D:2026-09-08")); + assertTrue(generation.value().contains("W:")); + assertTrue(generation.value().contains("M:2026-9")); + assertEquals(now + 43_200_000L, generation.expiresAt()); } @Test @@ -85,37 +104,36 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw Connection pendingConnection = mock(Connection.class); Connection cleanupConnection = mock(Connection.class); Connection debitConnection = mock(Connection.class); - Connection claimConnection = mock(Connection.class); Connection refundConnection = mock(Connection.class); PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement schemaGeneration = mock(PreparedStatement.class); + PreparedStatement schemaGenerationExpiry = mock(PreparedStatement.class); PreparedStatement schemaIndex = mock(PreparedStatement.class); PreparedStatement pending = mock(PreparedStatement.class); PreparedStatement cleanupSelect = mock(PreparedStatement.class); PreparedStatement cleanupDelete = mock(PreparedStatement.class); PreparedStatement reserve = mock(PreparedStatement.class); PreparedStatement debit = mock(PreparedStatement.class); - PreparedStatement claim = mock(PreparedStatement.class); PreparedStatement refundSelect = mock(PreparedStatement.class); PreparedStatement refund = mock(PreparedStatement.class); PreparedStatement refundUpdate = mock(PreparedStatement.class); ResultSet noPendingRows = emptyRows(); ResultSet noTerminalRows = emptyRows(); - ResultSet claimedPurchase = purchaseRow("HOOK_STARTED", "Points", null, 10); + ResultSet pendingPurchase = purchaseRow("PENDING", "Points", null, 10); 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(schemaConnection, pendingConnection, - cleanupConnection, debitConnection, claimConnection, refundConnection); - when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaIndex); + cleanupConnection, debitConnection, refundConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, + schemaGenerationExpiry, schemaIndex); when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); when(pending.executeQuery()).thenReturn(noPendingRows); when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); when(debitConnection.prepareStatement(anyString())).thenReturn(reserve, debit); - when(claimConnection.prepareStatement(anyString())).thenReturn(claim); - when(claim.executeUpdate()).thenReturn(1); when(refundConnection.prepareStatement(anyString())).thenReturn(refundSelect, refund, refundUpdate); - when(refundSelect.executeQuery()).thenReturn(claimedPurchase); + when(refundSelect.executeQuery()).thenReturn(pendingPurchase); when(debit.executeUpdate()).thenReturn(1); when(refund.executeUpdate()).thenReturn(1); when(refundUpdate.executeUpdate()).thenReturn(1); @@ -155,12 +173,19 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback( org.mockito.ArgumentMatchers.eq(player), scheduled.capture(), retirement.capture()); purchase.get(5, TimeUnit.SECONDS); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor, times(2)).execute(compensation.capture()); + compensation.getAllValues().get(1).run(); ArgumentCaptor refundSql = ArgumentCaptor.forClass(String.class); verify(refundConnection, times(3)).prepareStatement(refundSql.capture()); assertTrue(refundSql.getAllValues().get(1).contains("`Points` = `Points` + ?")); verify(refund).setInt(1, 10); verify(refund, times(1)).executeUpdate(); + // Schema, stale cleanup, terminal cleanup, reservation, and refund are the + // only database connections in the scheduler-retirement path. A sixth + // checkout would be the reward claim and would make the debit unrecoverable. + verify(sql.getConnectionManager(), times(5)).getConnection(); verify(entityScheduler).runAtEntityWithFallback( org.mockito.ArgumentMatchers.eq(player), any(), any(Runnable.class)); scheduled.getValue().accept(null); @@ -181,6 +206,43 @@ void disabledShopResultStillSendsFeedback() { verify(player).sendMessage(anyString()); } + @Test + void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + @SuppressWarnings("rawtypes") + ArgumentCaptor callback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + when(entityScheduler.runAtEntityWithFallback(any(), callback.capture(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.refundUnstartedReward("purchase-1")).thenReturn(false); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)); + VotingPluginUser user = mock(VotingPluginUser.class); + + service.scheduleClaimedReward(mock(org.bukkit.entity.Player.class), user, mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + + ArgumentCaptor refund = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(refund.capture()); + refund.getValue().run(); + verify(journal).refundUnstartedReward("purchase-1"); + callback.getValue().accept(null); + verify(rewardHandler, never()).giveReward(any(), any(), any(), any()); + } + @Test void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { MySQL table = mock(MySQL.class); @@ -311,6 +373,8 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti Connection claimConnection = mock(Connection.class); Connection completeConnection = mock(Connection.class); PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement schemaGeneration = mock(PreparedStatement.class); + PreparedStatement schemaGenerationExpiry = mock(PreparedStatement.class); PreparedStatement schemaIndex = mock(PreparedStatement.class); PreparedStatement pending = mock(PreparedStatement.class); PreparedStatement cleanupSelect = mock(PreparedStatement.class); @@ -328,14 +392,19 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti when(table.getMysql()).thenReturn(sql); when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, pendingConnection, cleanupConnection, reserveConnection, claimConnection, completeConnection); - when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaIndex); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, + schemaGenerationExpiry, schemaIndex); when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); when(pending.executeQuery()).thenReturn(noPendingRows); when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); when(reserveConnection.prepareStatement(anyString())).thenReturn(reserve, debit); when(debit.executeUpdate()).thenReturn(1); - when(claimConnection.prepareStatement(anyString())).thenReturn(claim); + AtomicReference claimThread = new AtomicReference<>(); + when(claimConnection.prepareStatement(anyString())).thenAnswer(invocation -> { + claimThread.set(Thread.currentThread().getName()); + return claim; + }); when(claim.executeUpdate()).thenReturn(1); when(completeConnection.prepareStatement(anyString())).thenReturn(completeSelect, completeUpdate); when(completeSelect.executeQuery()).thenReturn(hookStartedPurchase); @@ -352,6 +421,11 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + AtomicReference claimWork = new AtomicReference<>(); + doAnswer(invocation -> { + claimWork.set(invocation.getArgument(1, Runnable.class)); + return null; + }).when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); when(scheduler.getFoliaLib()).thenReturn(folia); when(folia.getImpl()).thenReturn(entityScheduler); when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) @@ -388,15 +462,29 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti ExecutorService worker = Executors.newSingleThreadExecutor(); Future purchase = worker.submit(work.getValue()); @SuppressWarnings("rawtypes") - ArgumentCaptor entityCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + ArgumentCaptor rewardCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); verify(entityScheduler, org.mockito.Mockito.timeout(1000)).runAtEntityWithFallback(any(), - entityCallback.capture(), any(Runnable.class)); - InOrder claimBeforeEntityWork = inOrder(claimConnection, entityScheduler); - claimBeforeEntityWork.verify(claimConnection).prepareStatement(anyString()); - claimBeforeEntityWork.verify(entityScheduler).runAtEntityWithFallback(any(), any(), any(Runnable.class)); - entityCallback.getValue().accept(null); + rewardCallback.capture(), any(Runnable.class)); + // A stopped JVM at this point must leave the durable row PENDING: the + // scheduler has accepted the reward callback but has not yet run it. + verify(claimConnection, never()).prepareStatement(anyString()); + rewardCallback.getValue().accept(null); + assertNotNull(claimWork.get(), "the entity gate must hand JDBC work to the async scheduler"); + verify(claimConnection, never()).prepareStatement(anyString()); + Thread claimWorker = new Thread(claimWork.get(), "vote-shop-claim-worker"); + claimWorker.start(); + claimWorker.join(1000); + assertFalse(claimWorker.isAlive()); + InOrder callbackBeforeClaim = inOrder(entityScheduler, claimConnection); + callbackBeforeClaim.verify(entityScheduler).runAtEntityWithFallback(any(), any(), any(Runnable.class)); + callbackBeforeClaim.verify(claimConnection).prepareStatement(anyString()); verify(completeConnection, never()).prepareStatement(anyString()); purchase.get(5, TimeUnit.SECONDS); + @SuppressWarnings("rawtypes") + ArgumentCaptor entityCallbacks = + ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(entityScheduler, times(2)).runAtEntityWithFallback(any(), entityCallbacks.capture(), any(Runnable.class)); + entityCallbacks.getAllValues().get(1).accept(null); ArgumentCaptor scheduledWork = ArgumentCaptor.forClass(Runnable.class); verify(persistenceExecutor, times(2)).execute(scheduledWork.capture()); scheduledWork.getAllValues().get(1).run(); @@ -404,6 +492,7 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti } assertEquals(VoteShopPurchaseResult.SUCCESS, result.get()); + assertEquals("vote-shop-claim-worker", claimThread.get(), "the entity callback must not perform JDBC"); verify(rewardHandler).giveReward(eq(user), eq(oldShopData), eq("Shop.old-item.Rewards"), any()); verify(rewardHandler, never()).giveReward(eq(user), eq(reloadedShopData), anyString(), any()); } From 0a4913e207a76567513f2c483c7094b177cb2f8b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:50:17 -0600 Subject: [PATCH 20/74] Harden shared MySQL point completion and recovery --- .../votingplugin/commands/CommandLoader.java | 57 ++++++++++++------ .../user/SharedMysqlPointMutator.java | 21 ++++++- .../votingplugin/user/VotingPluginUser.java | 33 ++++++++++- .../service/SharedMysqlPurchaseJournal.java | 58 +++++++++++++++++-- .../VotingPluginUserPointSchedulingTest.java | 44 +++++++++++++- .../SharedMysqlPurchaseJournalTest.java | 45 ++++++++++++-- .../service/VoteShopPurchaseServiceTest.java | 30 +++++++--- 7 files changed, 246 insertions(+), 42 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index fbcbf64a2..039456585 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -438,37 +438,56 @@ public void executeAll(CommandSender sender, String[] args) { return; } + java.util.List userIds = new java.util.ArrayList<>(plugin.getUserManager().getAllUUIDs()); + if (userIds.isEmpty()) { + sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); + return; + } sender.sendMessage( MessageAPI.colorize("&cGiving " + "all players" + " " + args[3] + " points")); - for (String uuidStr : plugin.getUserManager().getAllUUIDs()) { + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(userIds.size()); + java.util.concurrent.atomic.AtomicInteger updated = + new java.util.concurrent.atomic.AtomicInteger(); + for (String uuidStr : userIds) { UUID uuid = UUID.fromString(uuidStr); VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.addPointsStorageAware(num); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), - "amount", args[3]); - } + user.addPointsStorageAware(num, (success, ignored) -> { + if (success) { + updated.incrementAndGet(); + if (user.isOnline()) { + user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), + "amount", args[3]); + } + } + if (remaining.decrementAndGet() == 0) { + sender.sendMessage(MessageAPI.colorize("&cGave all players " + args[3] + + " points to " + updated.get() + "/" + userIds.size() + " players")); + plugin.getPlaceholders().onUpdate(); + } + }); } - sender.sendMessage(MessageAPI.colorize("&cGave " + "all players" + " " + args[3] + " points")); - - plugin.getPlaceholders().onUpdate(); } @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.cache(); - int newTotal = 0; - newTotal = user.addPointsStorageAware(Integer.parseInt(args[3])); - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), - "amount", args[3]); - } - sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + " " + args[3] + " points" + ", " - + args[1] + " now has " + newTotal + " points")); - - plugin.getPlaceholders().onUpdate(user, false); + int amount = Integer.parseInt(args[3]); + user.addPointsStorageAware(amount, (success, newTotal) -> { + if (!success) { + sender.sendMessage(MessageAPI.colorize("&cUnable to add " + args[3] + " points to " + args[1])); + return; + } + if (user.isOnline()) { + user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), + "amount", args[3]); + } + sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + " " + args[3] + " points" + ", " + + args[1] + " now has " + newTotal + " points")); + plugin.getPlaceholders().onUpdate(user, false); + }); } }); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 5fa10f854..8c8b0e65a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -63,6 +63,10 @@ int add(VotingPluginUser user, int amount, boolean async) { return addAndReadCommitted(user, amount); } + AddResult addCommitted(VotingPluginUser user, int amount) { + return addAndReadCommittedResult(user, amount); + } + void set(VotingPluginUser user, int value, boolean async) { run(() -> setAbsolute(user, value), async); } @@ -161,6 +165,10 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou logApprovalFailure(failure); return isAcceptedSettlement(outcome); } + // The approval hook may inspect or mutate the recipient and recreate its + // cache after the initial drain. Persist and remove that cache before the + // settlement credit so no queued pre-settlement value can overwrite it. + drainCache(target); SharedPointTransferJournal.SettlementOutcome outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, creditAmount); return isAcceptedSettlement(outcome); @@ -263,6 +271,10 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat * even when the caller requests {@code NO_CACHE}. */ private int addAndReadCommitted(VotingPluginUser user, int amount) { + return addAndReadCommittedResult(user, amount).total(); + } + + private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { drainCache(user); MySQL table = plugin.getMysql(); String points = user.getPointsPath(); @@ -275,17 +287,20 @@ private int addAndReadCommitted(VotingPluginUser user, int amount) { PreparedStatement readStatement = connection.prepareStatement(read)) { updateStatement.setInt(1, amount); updateStatement.setString(2, user.getUUID()); - if (updateStatement.executeUpdate() != 1) return user.getPoints(); + if (updateStatement.executeUpdate() != 1) return new AddResult(false, user.getPoints()); readStatement.setString(1, user.getUUID()); try (java.sql.ResultSet result = readStatement.executeQuery()) { - return result.next() ? result.getInt(1) : user.getPoints(); + return result.next() ? new AddResult(true, result.getInt(1)) + : new AddResult(false, user.getPoints()); } } catch (SQLException failure) { logFailure(failure); - return user.getPoints(); + return new AddResult(false, user.getPoints()); } } + record AddResult(boolean success, int total) {} + private void setAbsolute(VotingPluginUser user, int value) { drainCache(user); MySQL table = plugin.getMysql(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 76fec6814..e44fa66ea 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -13,8 +13,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Map.Entry; +import java.util.Map.Entry; import java.util.UUID; +import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -237,6 +238,36 @@ public synchronized int addPoints(int value, boolean async) { public int addPointsStorageAware(int value) { return addPoints(value, new SharedMysqlPointMutator(plugin).applies()); } + + /** + * Adds points and reports the committed total after shared-MySQL persistence + * completes. The callback runs on the user's Bukkit/entity lane. + */ + public void addPointsStorageAware(int value, Consumer completion) { + addPointsStorageAware(value, (success, total) -> completion.accept(total)); + } + + /** Adds points and reports both persistence success and the committed total. */ + public synchronized void addPointsStorageAware(int value, BiConsumer completion) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + completion.accept(false, getPoints()); + return; + } + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, false); + completion.accept(true, newTotal); + return; + } + Player player = getPlayer(); + plugin.getTimer().execute(() -> { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(result.success(), result.total()), player); + }); + } /** * Adds one to the total votes. 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 index d8317786d..4fa6f3f17 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -31,6 +31,7 @@ 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"; @@ -230,12 +231,41 @@ boolean refundPending(String purchaseId, long now) throws SQLException { /** * Compensates a pending or claimed purchase only when the local scheduler - * guard proves that its reward callback cannot run. This is never used by - * stale recovery, which must leave arbitrary HOOK_STARTED work for - * reconciliation. + * 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 { - return setTerminal(purchaseId, REFUNDED, System.currentTimeMillis(), PENDING, HOOK_STARTED); + SQLException lastFailure = null; + for (int attempt = 0; attempt < 3; attempt++) { + try { + if (!requestUnstartedRewardRefund(purchaseId)) return false; + return refundCompensatingReward(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)) { + 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; + connection.commit(); + return true; + } + } + + /** Retries the already-marked compensation without reopening the hook. */ + private boolean refundCompensatingReward(String purchaseId) throws SQLException { + return setTerminal(purchaseId, REFUNDED, System.currentTimeMillis(), COMPENSATING); } private boolean setTerminal(String purchaseId, String terminalState, long now, String... refundableStates) @@ -346,9 +376,29 @@ void recoverAndCleanup(long now) throws SQLException { } } for (String purchaseId : pending) refundPending(purchaseId, now); + // 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)) { + refundCompensatingReward(purchaseId); + } cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS); } + 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 ?"; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 1c1d60cfb..94c8f7d48 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -35,6 +35,7 @@ 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.votingplugin.VotingPluginMain; @@ -123,6 +124,41 @@ void sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal( 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 sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { PointFixture fixture = pointFixture(); @@ -324,7 +360,9 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme pluginField.set(target, fixture.plugin); doReturn("00000000-0000-0000-0000-000000000002").when(target).getUUID(); doReturn("Points").when(target).getPointsPath(); - doReturn(false).when(target).isCached(); + UserDataCache recreatedCache = mock(UserDataCache.class); + doReturn(false, true).when(target).isCached(); + doReturn(recreatedCache).when(target).getCache(); UserData targetData = mock(UserData.class); doReturn(targetData).when(target).getUserData(); doAnswer(invocation -> { @@ -354,11 +392,13 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme } InOrder order = org.mockito.Mockito.inOrder(fixture.reservation, fixture.claim, fixture.listenerRead, + recreatedCache, fixture.settlement); order.verify(fixture.reservation).close(); order.verify(fixture.claim).close(); order.verify(fixture.listenerRead).close(); - verify(fixture.settlement).commit(); + order.verify(recreatedCache).dump(); + order.verify(fixture.settlement).commit(); assertEquals(Boolean.TRUE, result.get()); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 3579aa01d..d163653f9 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -57,26 +57,31 @@ void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { void recoveryRefundsOnlyExpiredPendingPurchase() throws Exception { Fixture fixture = fixture(); Connection candidates = mock(Connection.class); + Connection compensatingCandidates = mock(Connection.class); Connection refund = mock(Connection.class); Connection cleanup = mock(Connection.class); PreparedStatement candidateStatement = mock(PreparedStatement.class); + PreparedStatement compensatingStatement = mock(PreparedStatement.class); PreparedStatement select = mock(PreparedStatement.class); PreparedStatement credit = mock(PreparedStatement.class); PreparedStatement terminal = mock(PreparedStatement.class); PreparedStatement cleanupSelect = mock(PreparedStatement.class); PreparedStatement cleanupDelete = mock(PreparedStatement.class); ResultSet expiredPending = ids("expired-pending"); + ResultSet noCompensating = ids(); ResultSet pending = pendingRow(); ResultSet noTerminalRows = ids(); when(candidates.prepareStatement(anyString())).thenReturn(candidateStatement); when(candidateStatement.executeQuery()).thenReturn(expiredPending); + when(compensatingCandidates.prepareStatement(anyString())).thenReturn(compensatingStatement); + when(compensatingStatement.executeQuery()).thenReturn(noCompensating); when(refund.prepareStatement(anyString())).thenReturn(select, credit, terminal); when(select.executeQuery()).thenReturn(pending); when(credit.executeUpdate()).thenReturn(1); when(terminal.executeUpdate()).thenReturn(1); when(cleanup.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); - when(fixture.sql.getConnectionManager().getConnection()).thenReturn(candidates, refund, cleanup); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(candidates, refund, compensatingCandidates, cleanup); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); journal.recoverAndCleanup(SharedMysqlPurchaseJournal.PENDING_RECOVERY_AGE_MILLIS + 1L); @@ -146,12 +151,14 @@ void refundStillReleasesALimitThatHasNoConfiguredReset() throws Exception { @Test void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { Fixture fixture = fixture(); + PreparedStatement markCompensating = mock(PreparedStatement.class); PreparedStatement select = mock(PreparedStatement.class); PreparedStatement credit = mock(PreparedStatement.class); PreparedStatement terminal = mock(PreparedStatement.class); - ResultSet hookStarted = pendingRow("HOOK_STARTED"); - when(fixture.work.prepareStatement(anyString())).thenReturn(select, credit, terminal); - when(select.executeQuery()).thenReturn(hookStarted); + ResultSet compensating = pendingRow("COMPENSATING"); + when(fixture.work.prepareStatement(anyString())).thenReturn(markCompensating, select, credit, terminal); + when(markCompensating.executeUpdate()).thenReturn(1); + when(select.executeQuery()).thenReturn(compensating); when(credit.executeUpdate()).thenReturn(1); when(terminal.executeUpdate()).thenReturn(1); @@ -161,6 +168,36 @@ void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { verify(terminal).setString(1, "REFUNDED"); } + @Test + void failedCompensationIsRetriedWithTheDurableMarker() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markFirst = mock(PreparedStatement.class); + PreparedStatement selectFirst = mock(PreparedStatement.class); + PreparedStatement creditFirst = mock(PreparedStatement.class); + PreparedStatement markSecond = mock(PreparedStatement.class); + PreparedStatement selectSecond = mock(PreparedStatement.class); + PreparedStatement creditSecond = mock(PreparedStatement.class); + PreparedStatement terminalSecond = mock(PreparedStatement.class); + ResultSet compensating = pendingRow("COMPENSATING"); + when(fixture.work.prepareStatement(anyString())).thenReturn(markFirst, selectFirst, creditFirst, + markSecond, selectSecond, creditSecond, terminalSecond); + when(markFirst.executeUpdate()).thenReturn(1); + when(selectFirst.executeQuery()).thenReturn(compensating); + doThrow(new java.sql.SQLException("temporary database outage")).when(creditFirst).executeUpdate(); + when(markSecond.executeUpdate()).thenReturn(1); + when(selectSecond.executeQuery()).thenReturn(compensating); + when(creditSecond.executeUpdate()).thenReturn(1); + when(terminalSecond.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundUnstartedReward("retry-compensation")); + + verify(markFirst).setString(1, "COMPENSATING"); + verify(markSecond).setString(1, "COMPENSATING"); + verify(creditSecond).executeUpdate(); + verify(terminalSecond).setString(1, "REFUNDED"); + } + @Test void ambiguousReservationCommitIsConfirmedAfterItsConnectionIsReleased() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 49b913e00..2a1b50548 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -102,6 +102,7 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw org.mockito.Mockito.RETURNS_DEEP_STUBS); Connection schemaConnection = mock(Connection.class); Connection pendingConnection = mock(Connection.class); + Connection compensatingConnection = mock(Connection.class); Connection cleanupConnection = mock(Connection.class); Connection debitConnection = mock(Connection.class); Connection refundConnection = mock(Connection.class); @@ -110,31 +111,37 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw PreparedStatement schemaGenerationExpiry = mock(PreparedStatement.class); PreparedStatement schemaIndex = mock(PreparedStatement.class); PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement compensating = mock(PreparedStatement.class); PreparedStatement cleanupSelect = mock(PreparedStatement.class); PreparedStatement cleanupDelete = mock(PreparedStatement.class); PreparedStatement reserve = mock(PreparedStatement.class); PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement refundMark = mock(PreparedStatement.class); PreparedStatement refundSelect = mock(PreparedStatement.class); PreparedStatement refund = mock(PreparedStatement.class); PreparedStatement refundUpdate = mock(PreparedStatement.class); ResultSet noPendingRows = emptyRows(); + ResultSet noCompensatingRows = emptyRows(); ResultSet noTerminalRows = emptyRows(); - ResultSet pendingPurchase = purchaseRow("PENDING", "Points", null, 10); + ResultSet pendingPurchase = purchaseRow("COMPENSATING", "Points", null, 10); 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(schemaConnection, pendingConnection, - cleanupConnection, debitConnection, refundConnection); + compensatingConnection, cleanupConnection, debitConnection, refundConnection); when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, schemaGenerationExpiry, schemaIndex); when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); when(pending.executeQuery()).thenReturn(noPendingRows); + when(compensatingConnection.prepareStatement(anyString())).thenReturn(compensating); + when(compensating.executeQuery()).thenReturn(noCompensatingRows); when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); when(debitConnection.prepareStatement(anyString())).thenReturn(reserve, debit); - when(refundConnection.prepareStatement(anyString())).thenReturn(refundSelect, refund, refundUpdate); + when(refundConnection.prepareStatement(anyString())).thenReturn(refundMark, refundSelect, refund, refundUpdate); when(refundSelect.executeQuery()).thenReturn(pendingPurchase); when(debit.executeUpdate()).thenReturn(1); + when(refundMark.executeUpdate()).thenReturn(1); when(refund.executeUpdate()).thenReturn(1); when(refundUpdate.executeUpdate()).thenReturn(1); VotingPluginMain plugin = sharedMysqlPlugin(table); @@ -178,14 +185,14 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw compensation.getAllValues().get(1).run(); ArgumentCaptor refundSql = ArgumentCaptor.forClass(String.class); - verify(refundConnection, times(3)).prepareStatement(refundSql.capture()); - assertTrue(refundSql.getAllValues().get(1).contains("`Points` = `Points` + ?")); + verify(refundConnection, times(4)).prepareStatement(refundSql.capture()); + assertTrue(refundSql.getAllValues().get(2).contains("`Points` = `Points` + ?")); verify(refund).setInt(1, 10); verify(refund, times(1)).executeUpdate(); - // Schema, stale cleanup, terminal cleanup, reservation, and refund are the - // only database connections in the scheduler-retirement path. A sixth + // Schema, stale cleanup, compensating cleanup, terminal cleanup, reservation, + // and refund are the only database connections in the scheduler-retirement path. An eighth // checkout would be the reward claim and would make the debit unrecoverable. - verify(sql.getConnectionManager(), times(5)).getConnection(); + verify(sql.getConnectionManager(), times(7)).getConnection(); verify(entityScheduler).runAtEntityWithFallback( org.mockito.ArgumentMatchers.eq(player), any(), any(Runnable.class)); scheduled.getValue().accept(null); @@ -368,6 +375,7 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti org.mockito.Mockito.RETURNS_DEEP_STUBS); Connection schemaConnection = mock(Connection.class); Connection pendingConnection = mock(Connection.class); + Connection compensatingConnection = mock(Connection.class); Connection cleanupConnection = mock(Connection.class); Connection reserveConnection = mock(Connection.class); Connection claimConnection = mock(Connection.class); @@ -377,6 +385,7 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti PreparedStatement schemaGenerationExpiry = mock(PreparedStatement.class); PreparedStatement schemaIndex = mock(PreparedStatement.class); PreparedStatement pending = mock(PreparedStatement.class); + PreparedStatement compensating = mock(PreparedStatement.class); PreparedStatement cleanupSelect = mock(PreparedStatement.class); PreparedStatement cleanupDelete = mock(PreparedStatement.class); PreparedStatement reserve = mock(PreparedStatement.class); @@ -385,17 +394,20 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti PreparedStatement completeSelect = mock(PreparedStatement.class); PreparedStatement completeUpdate = mock(PreparedStatement.class); ResultSet noPendingRows = emptyRows(); + ResultSet noCompensatingRows = emptyRows(); ResultSet noTerminalRows = emptyRows(); ResultSet hookStartedPurchase = purchaseRow("HOOK_STARTED", "Points", null, 10); 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(schemaConnection, pendingConnection, - cleanupConnection, reserveConnection, claimConnection, completeConnection); + compensatingConnection, cleanupConnection, reserveConnection, claimConnection, completeConnection); when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, schemaGenerationExpiry, schemaIndex); when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); when(pending.executeQuery()).thenReturn(noPendingRows); + when(compensatingConnection.prepareStatement(anyString())).thenReturn(compensating); + when(compensating.executeQuery()).thenReturn(noCompensatingRows); when(cleanupConnection.prepareStatement(anyString())).thenReturn(cleanupSelect, cleanupDelete); when(cleanupSelect.executeQuery()).thenReturn(noTerminalRows); when(reserveConnection.prepareStatement(anyString())).thenReturn(reserve, debit); From 58c5d0d02f46a7ee1290fcee3b77088c119be20e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 12:45:48 -0600 Subject: [PATCH 21/74] Close shared point settlement edge cases --- .../rewards/builtin/RewardPoints.java | 4 +- .../user/SharedMysqlPointMutator.java | 121 +++++++++++++++++- .../votingplugin/user/VotingPluginUser.java | 8 +- .../service/SharedMysqlPurchaseJournal.java | 2 +- .../user/SharedMysqlPointMutatorTest.java | 30 +++++ .../VotingPluginUserPointSchedulingTest.java | 73 ++++++++++- .../SharedMysqlPurchaseJournalTest.java | 25 ++++ 7 files changed, 244 insertions(+), 19 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index 803bca4cf..eb26a506a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -46,7 +46,9 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - String result = "" + vpUser.addPointsStorageAware(num); + // RewardInjectInt is synchronous: later rewards and the newpoints + // placeholder must observe the committed shared-MySQL total. + String result = "" + vpUser.addPoints(num); plugin.debug("Setting points to " + result); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 8c8b0e65a..1409cbd44 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -5,6 +5,7 @@ import java.sql.SQLException; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import java.util.function.IntFunction; import com.bencodez.advancedcore.api.user.UserStorage; @@ -160,8 +161,16 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou try { creditAmount = creditAmountProvider.apply(debitAmount); } catch (RuntimeException failure) { - SharedPointTransferJournal.SettlementOutcome outcome = journal.settleWithConfirmation(transferId, owner, - source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, null); + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, null); + } finally { + // A listener may have recreated the recipient cache while the + // settlement transaction was running. Discard it after the + // transaction without dumping stale values back to storage. + discardCache(target); + } logApprovalFailure(failure); return isAcceptedSettlement(outcome); } @@ -169,8 +178,16 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou // cache after the initial drain. Persist and remove that cache before the // settlement credit so no queued pre-settlement value can overwrite it. drainCache(target); - SharedPointTransferJournal.SettlementOutcome outcome = journal.settleWithConfirmation(transferId, owner, - source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, creditAmount); + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, creditAmount); + } finally { + // A concurrent lookup can recreate the cache after the final + // pre-settlement drain. Never dump that stale snapshot after the + // credit commits; remove it instead. + discardCache(target); + } return isAcceptedSettlement(outcome); } catch (SQLException failure) { logFailure(failure); @@ -178,6 +195,83 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou } } + /** + * Runs the durable transfer phases around a Bukkit-thread approval hook. The + * reservation and claim happen on the persistence executor, the arbitrary + * listener runs on Bukkit's thread, and settlement returns to persistence + * before the completion callback is posted back to the source entity lane. + */ + void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, + IntFunction creditAmountProvider, Consumer completion) { + plugin.getTimer().execute(() -> { + drainCache(source); + drainCache(target); + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); + String transferId = UUID.randomUUID().toString(); + String owner = UUID.randomUUID().toString(); + SharedPointTransferJournal journal; + try { + journal = SharedPointTransferJournal.forTable(table); + journal.recoverAndCleanup(System.currentTimeMillis()); + if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, + System.currentTimeMillis())) { + completeOnBukkit(source, completion, false); + return; + } + SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, + System.currentTimeMillis()); + if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + completeOnBukkit(source, completion, false); + return; + } + if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { + logIndeterminateClaim(transferId); + completeOnBukkit(source, completion, true); + return; + } + } catch (SQLException failure) { + logFailure(failure); + completeOnBukkit(source, completion, false); + return; + } + + plugin.getBukkitScheduler().runTask(plugin, () -> { + Integer creditAmount; + try { + creditAmount = creditAmountProvider.apply(debitAmount); + } catch (RuntimeException failure) { + creditAmount = null; + logApprovalFailure(failure); + } + Integer approvedAmount = creditAmount; + plugin.getTimer().execute(() -> { + boolean transferred; + try { + // The hook may have recreated the cache while it ran on Bukkit. + drainCache(target); + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, approvedAmount); + } finally { + discardCache(target); + } + transferred = isAcceptedSettlement(outcome); + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to settle shared MySQL point transfer: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + transferred = false; + } + completeOnBukkit(source, completion, transferred); + }); + }); + }); + } + private boolean isAcceptedSettlement(SharedPointTransferJournal.SettlementOutcome outcome) { if (outcome == SharedPointTransferJournal.SettlementOutcome.INDETERMINATE) { // The callback has already run. Reporting a retryable failure could create @@ -282,20 +376,25 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = " + table.qi(points) + " + ? WHERE " + uuidMatch; String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch; + boolean updateCommitted = false; try (Connection connection = table.getMysql().getConnectionManager().getConnection(); PreparedStatement updateStatement = connection.prepareStatement(update); PreparedStatement readStatement = connection.prepareStatement(read)) { updateStatement.setInt(1, amount); updateStatement.setString(2, user.getUUID()); if (updateStatement.executeUpdate() != 1) return new AddResult(false, user.getPoints()); + // With JDBC auto-commit, executeUpdate returning one means the mutation + // completed. A later read may still fail after the points have been + // committed, so never turn that outcome into a retryable failure. + updateCommitted = true; readStatement.setString(1, user.getUUID()); try (java.sql.ResultSet result = readStatement.executeQuery()) { return result.next() ? new AddResult(true, result.getInt(1)) - : new AddResult(false, user.getPoints()); + : new AddResult(updateCommitted, user.getPoints()); } } catch (SQLException failure) { logFailure(failure); - return new AddResult(false, user.getPoints()); + return new AddResult(updateCommitted, user.getPoints()); } } @@ -341,6 +440,16 @@ private void drainCache(VotingPluginUser user) { } } + private void discardCache(VotingPluginUser user) { + if (user.isCached()) { + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } + } + + private void completeOnBukkit(VotingPluginUser source, Consumer completion, boolean transferred) { + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), source.getPlayer()); + } + private void logFailure(SQLException failure) { plugin.getLogger().severe("Unable to update shared MySQL vote points: " + failure.getClass().getSimpleName()); plugin.debug(failure); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index e44fa66ea..50a9f4052 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1403,15 +1403,11 @@ public void removePoints(int points, Consumer completion) { public void transferPoints(VotingPluginUser target, int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.applies()) { - Player player = getPlayer(); - plugin.getTimer().execute(() -> { - boolean transferred = sharedPoints.transfer(this, target, points, ignored -> { + sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points); Bukkit.getPluginManager().callEvent(receiveEvent); return receiveEvent.isCancelled() ? null : receiveEvent.getPoints(); - }); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), player); - }); + }, completion); return; } boolean transferred = removePoints(points); 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 index 4fa6f3f17..520b650a2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -465,7 +465,7 @@ private Connection connection() throws SQLException { private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } private static boolean isSafeColumn(String column) { - return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + return column != null && column.matches("[A-Za-z][A-Za-z0-9_-]{0,127}"); } private static void rollback(Connection connection) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index e45997fd2..785705c7e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -189,6 +189,36 @@ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exce 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(); + } + @Test void capUsesLeastSoItCannotRestoreAConcurrentDebit() throws Exception { MySQL table = mock(MySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 94c8f7d48..56c361606 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -36,6 +36,7 @@ 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.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.sql.mysql.ConnectionManager; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.votingplugin.VotingPluginMain; @@ -236,6 +237,39 @@ void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throw verify(fixture.sql.getConnectionManager()).getConnection(); } + @Test + void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + PlayerReceivePointsEvent event = invocation.getArgument(0); + event.setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor firstPersistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(firstPersistence.capture()); + firstPersistence.getValue().run(); + + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + assertEquals(null, result.get()); + approval.getValue().run(); + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(persistence.capture()); + persistence.getAllValues().get(1).run(); + } + + assertEquals(null, result.get()); + } + @Test void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { SagaFixture fixture = sagaFixture(true); @@ -252,14 +286,19 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { fixture.user.transferPoints(fixture.target, 10, result::set); ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); - Thread persistenceThread = Thread.currentThread(); persistenceWork.getValue().run(); + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + approval.getValue().run(); + Thread bukkitThread = eventThread.get(); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(1).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); assertTrue(result.get() == null); completion.getValue().run(); - assertEquals(persistenceThread, eventThread.get(), - "the asynchronous receive hook must not rendezvous with the server thread inside the transaction"); + assertEquals(bukkitThread, eventThread.get(), "the receive hook must run on Bukkit's scheduler lane"); } assertTrue(result.get()); } @@ -282,6 +321,12 @@ void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exc ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + approval.getValue().run(); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(1).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); @@ -337,6 +382,12 @@ void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + approval.getValue().run(); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(1).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); @@ -386,19 +437,26 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + approval.getValue().run(); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(1).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); } InOrder order = org.mockito.Mockito.inOrder(fixture.reservation, fixture.claim, fixture.listenerRead, - recreatedCache, - fixture.settlement); + recreatedCache, fixture.settlement, fixture.plugin.getUserManager().getDataManager()); order.verify(fixture.reservation).close(); order.verify(fixture.claim).close(); order.verify(fixture.listenerRead).close(); order.verify(recreatedCache).dump(); order.verify(fixture.settlement).commit(); + order.verify((UserDataManager) fixture.plugin.getUserManager().getDataManager()).removeCache( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000002"), null); assertEquals(Boolean.TRUE, result.get()); } @@ -590,6 +648,10 @@ private static TransferSchedulingFixture transferSchedulingFixture() throws Exce doReturn("Points").when(fixture.user).getPointsPath(); doReturn(fixture.player).when(fixture.user).getPlayer(); doReturn(false).when(fixture.user).isCached(); + fixture.target = mock(VotingPluginUser.class); + when(fixture.target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); + when(fixture.target.getPointsPath()).thenReturn("Points"); + when(fixture.target.isCached()).thenReturn(false); return fixture; } @@ -658,6 +720,7 @@ private static final class TransferSchedulingFixture { Connection listenerRead; Connection settlement; VotingPluginUser user; + VotingPluginUser target; } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index d163653f9..87fc540d5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -168,6 +168,31 @@ void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { verify(terminal).setString(1, "REFUNDED"); } + @Test + void refundAcceptsHyphenatedConfiguredLimitIdentifier() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + when(pending.getString(4)).thenReturn("VoteShopLimitdaily-key"); + when(pending.getString(6)).thenReturn(SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); + when(select.executeQuery()).thenReturn(pending); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + boolean refunded = journal.refundPending("hyphenated-limit", 100L); + verify(refund).executeUpdate(); + verify(terminal).executeUpdate(); + assertTrue(refunded); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(1).contains("VoteShopLimitdaily-key")); + } + @Test void failedCompensationIsRetriedWithTheDurableMarker() throws Exception { Fixture fixture = fixture(); From 4928b10f0f794699addcdedb16efa14d18338594 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 14:08:56 -0600 Subject: [PATCH 22/74] Close shared point settlement edge cases --- .../user/SharedMysqlPointMutator.java | 188 ++++++++++++++---- .../user/SharedPointTransferJournal.java | 58 ++++++ .../service/SharedMysqlPurchaseJournal.java | 18 +- .../user/SharedMysqlPointMutatorTest.java | 27 +++ .../user/SharedPointTransferJournalTest.java | 21 ++ .../VotingPluginUserPointSchedulingTest.java | 154 +++++++++++--- .../SharedMysqlPurchaseJournalTest.java | 79 ++++++++ 7 files changed, 474 insertions(+), 71 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 1409cbd44..2d953b5b8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -4,7 +4,9 @@ import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; import java.util.function.IntFunction; @@ -14,6 +16,7 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.sql.mysql.DbType; import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.votingplugin.VotingPluginMain; /** Performs point writes that must remain atomic across shared MySQL servers. */ @@ -220,56 +223,155 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target completeOnBukkit(source, completion, false); return; } - SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, - System.currentTimeMillis()); - if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { - journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); - completeOnBukkit(source, completion, false); - return; - } - if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { - logIndeterminateClaim(transferId); - completeOnBukkit(source, completion, true); - return; - } + // The source cache may have been recreated while the reservation was being + // committed. Discard it after the durable debit, before any later dump can + // restore the pre-debit balance. + discardCache(source); } catch (SQLException failure) { logFailure(failure); completeOnBukkit(source, completion, false); return; } - plugin.getBukkitScheduler().runTask(plugin, () -> { - Integer creditAmount; + /* + * Do not claim the reservation until the Bukkit approval task has actually + * started. If scheduling is rejected, the row remains RESERVED and startup + * recovery can safely return the debit. JDBC claim work remains on the + * persistence executor, never on the Bukkit lane. + */ + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + try { + plugin.getTimer().execute(() -> claimTransferForApproval(source, target, debitAmount, + creditAmountProvider, completion, journal, transferId, owner, sourcePoints, targetPoints)); + } catch (RuntimeException schedulingFailure) { + refundReservedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, debitAmount, + schedulingFailure); + } + }); + } catch (RuntimeException schedulingFailure) { + refundReservedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, debitAmount, + schedulingFailure); + } + }); + } + + private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, + IntFunction creditAmountProvider, Consumer completion, + SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints) { + SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, + System.currentTimeMillis()); + if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { + try { + journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + discardCache(source); + } catch (SQLException failure) { + logFailure(failure); + } + completeOnBukkit(source, completion, false); + return; + } + if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { + logIndeterminateClaim(transferId); + completeOnBukkit(source, completion, true); + return; + } + discardCache(source); + org.bukkit.entity.Player targetPlayer = target.getPlayer(); + org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : source.getPlayer(); + AtomicInteger approvalState = new AtomicInteger(0); + Runnable rejectBeforeStart = () -> { + if (!approvalState.compareAndSet(0, 2)) return; + try { + plugin.getTimer().execute(() -> refundClaimedAfterSchedulingFailure(source, completion, journal, + transferId, sourcePoints, debitAmount, + new IllegalStateException("Transfer approval task did not start"))); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + completeOnBukkit(source, completion, false); + } + }; + try { + CompletableFuture approval = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(approvalPlayer, ignored -> { + if (!approvalState.compareAndSet(0, 1)) return; + Integer approvedAmount; try { - creditAmount = creditAmountProvider.apply(debitAmount); + approvedAmount = creditAmountProvider.apply(debitAmount); } catch (RuntimeException failure) { - creditAmount = null; + approvedAmount = null; logApprovalFailure(failure); } - Integer approvedAmount = creditAmount; - plugin.getTimer().execute(() -> { - boolean transferred; - try { - // The hook may have recreated the cache while it ran on Bukkit. - drainCache(target); - SharedPointTransferJournal.SettlementOutcome outcome; - try { - outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, - target.getUUID(), targetPoints, debitAmount, approvedAmount); - } finally { - discardCache(target); - } - transferred = isAcceptedSettlement(outcome); - } catch (RuntimeException failure) { - plugin.getLogger().severe("Unable to settle shared MySQL point transfer: " - + failure.getClass().getSimpleName()); - plugin.debug(failure); - transferred = false; - } - completeOnBukkit(source, completion, transferred); - }); + Integer finalApprovedAmount = approvedAmount; + try { + plugin.getTimer().execute(() -> settleTransfer(source, target, debitAmount, completion, journal, + transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); + } catch (RuntimeException schedulingFailure) { + // The approval callback already ran and may have had side effects. Keep the + // claimed row for explicit reconciliation instead of refunding it. + plugin.debug(schedulingFailure); + logIndeterminateClaim(transferId); + completeOnBukkit(source, completion, true); + } finally { + approvalState.set(2); + } + }, rejectBeforeStart); + approval.whenComplete((result, failure) -> { + if (failure != null || result != EntityTaskResult.SUCCESS) rejectBeforeStart.run(); }); - }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + rejectBeforeStart.run(); + } + } + + private void settleTransfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, + Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, + String sourcePoints, String targetPoints, Integer approvedAmount) { + boolean transferred; + try { + // The hook may have recreated either cache while it ran on Bukkit. + drainCache(target); + SharedPointTransferJournal.SettlementOutcome outcome; + try { + outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, + target.getUUID(), targetPoints, debitAmount, approvedAmount); + } finally { + discardCache(source); + discardCache(target); + } + transferred = isAcceptedSettlement(outcome); + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to settle shared MySQL point transfer: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + transferred = false; + } + completeOnBukkit(source, completion, transferred); + } + + private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, + RuntimeException failure) { + try { + if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) discardCache(source); + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + plugin.debug(failure); + completeOnBukkit(source, completion, false); + } + + private void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, + RuntimeException failure) { + try { + if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) discardCache(source); + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + plugin.debug(failure); + completeOnBukkit(source, completion, false); } private boolean isAcceptedSettlement(SharedPointTransferJournal.SettlementOutcome outcome) { @@ -356,6 +458,8 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat } catch (SQLException failure) { logFailure(failure); return false; + } finally { + discardCache(user); } } @@ -395,6 +499,8 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { } catch (SQLException failure) { logFailure(failure); return new AddResult(updateCommitted, user.getPoints()); + } finally { + discardCache(user); } } @@ -413,6 +519,8 @@ private void setAbsolute(VotingPluginUser user, int value) { statement.executeUpdate(); } catch (SQLException failure) { logFailure(failure); + } finally { + discardCache(user); } } @@ -430,6 +538,8 @@ private void capAt(VotingPluginUser user, int maximum) { statement.executeUpdate(); } catch (SQLException failure) { logFailure(failure); + } finally { + discardCache(user); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index d290123da..4e086fbfd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -257,6 +257,7 @@ enum ClaimOutcome { */ boolean refundReserved(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints) throws SQLException { + if (!isSafeColumn(sourcePointsColumn)) return false; String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; String points = qi(sourcePointsColumn); @@ -295,6 +296,63 @@ boolean refundReserved(String transferId, String sourceUuid, String sourcePoints } } + /** + * Refunds a claimed transfer when the second Bukkit approval task was rejected + * before its callback could start. The caller has the scheduler's proof that + * no listener ran, so it is safe to reverse the source debit. + */ + boolean refundHookStarted(String transferId, String sourceUuid, String sourcePointsColumn, int debitPoints) + throws SQLException { + if (!isSafeColumn(sourcePointsColumn)) return false; + String select = "SELECT " + qi("state") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + + " = ? FOR UPDATE"; + String points = qi(sourcePointsColumn); + String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("transfer_id") + + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, transferId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + connection.rollback(); + return false; + } + String state = result.getString(1); + if (REFUNDED.equals(state)) { + connection.rollback(); + return true; + } + if (!HOOK_STARTED.equals(state)) { + connection.rollback(); + return false; + } + } + } + try (PreparedStatement refundStatement = connection.prepareStatement(refund)) { + refundStatement.setInt(1, debitPoints); + refundStatement.setString(2, sourceUuid); + if (refundStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, REFUNDED); + updateStatement.setString(2, transferId); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + } + return commitAndConfirm(connection, transferId, REFUNDED); + } catch (SQLException failure) { + throw failure; + } + } + /** * Retries settlement with the same transfer id when a commit acknowledgement * or its first confirmation read is lost. A terminal state proves the prior 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 index 520b650a2..470c1d7f6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -238,7 +238,10 @@ boolean refundUnstartedReward(String purchaseId) throws SQLException { SQLException lastFailure = null; for (int attempt = 0; attempt < 3; attempt++) { try { - if (!requestUnstartedRewardRefund(purchaseId)) return false; + if (!requestUnstartedRewardRefund(purchaseId)) { + PurchaseRow row = find(purchaseId); + return row != null && REFUNDED.equals(row.state()); + } return refundCompensatingReward(purchaseId); } catch (SQLException failure) { lastFailure = failure; @@ -252,14 +255,20 @@ private boolean requestUnstartedRewardRefund(String purchaseId) throws SQLExcept 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; - connection.commit(); - return true; + try { + connection.commit(); + return true; + } catch (SQLException failure) { + rollback(connection); + throw failure; + } } } @@ -319,8 +328,7 @@ private boolean setTerminal(String purchaseId, String terminalState, long now, S return false; } } - connection.commit(); - return true; + return commitAndConfirm(connection, purchaseId, terminalState); } catch (SQLException failure) { throw failure; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 785705c7e..3447decfb 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -87,6 +87,33 @@ void removeReportsARejectedConditionalDebit() throws Exception { assertFalse(new SharedMysqlPointMutator(plugin).remove(user, 10)); } + @Test + void pointMutationDiscardsCacheRecreatedDuringDatabaseWrite() 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()).removeCache( + java.util.UUID.fromString(user.getUUID()), null); + } + @Test void asynchronousRemoveDoesNotAcquireJdbcOnTheCallerThread() throws Exception { MySQL table = mock(MySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java index 2598d3f63..adb3c1fb8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -108,6 +108,27 @@ void cancelledHookRefundsExactlyTheReservedDebit() throws Exception { 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 acceptedHookCreditsAdjustedAmountAndMarksTerminalState() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 56c361606..1ef9ae734 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -21,6 +21,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; import org.bukkit.entity.Player; @@ -39,6 +40,9 @@ import com.bencodez.advancedcore.api.user.usercache.UserDataManager; 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; @@ -253,23 +257,80 @@ void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() thro fixture.user.transferPoints(fixture.target, 10, result::set); ArgumentCaptor firstPersistence = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence).execute(firstPersistence.capture()); - firstPersistence.getValue().run(); + verify(fixture.persistence).execute(firstPersistence.capture()); + firstPersistence.getValue().run(); - ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); - assertEquals(null, result.get()); + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + verify(fixture.claim, never()).prepareStatement(any(String.class)); + assertEquals(null, result.get()); approval.getValue().run(); - verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); - ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(persistence.capture()); persistence.getAllValues().get(1).run(); + @SuppressWarnings("rawtypes") + ArgumentCaptor event = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.targetPlayer), event.capture(), any(Runnable.class)); + event.getValue().accept(null); + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + + ArgumentCaptor settlement = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlement.capture()); + settlement.getAllValues().get(2).run(); } assertEquals(null, result.get()); } + @Test + void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + 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 claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + claimed.getAllValues().get(1).run(); + ArgumentCaptor refunded = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(refunded.capture()); + refunded.getAllValues().get(2).run(); + + verify(fixture.settlementPoint).setInt(1, 10); + verify(fixture.settlementPoint).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + + @Test + void offlineTargetApprovalFallsBackToTheOnlineSourceEntityLane() throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.target.getPlayer()).thenReturn(null); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(reservation.capture()); + reservation.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.player); + } + + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)); + } + @Test void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { SagaFixture fixture = sagaFixture(true); @@ -287,13 +348,12 @@ void sharedTransferReportsCompletionOnSourceEntityScheduler() throws Exception { ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); - ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); - approval.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); Thread bukkitThread = eventThread.get(); ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); - settlementWork.getAllValues().get(1).run(); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); assertTrue(result.get() == null); @@ -321,12 +381,11 @@ void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exc ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); - ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); - approval.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); - settlementWork.getAllValues().get(1).run(); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); @@ -382,12 +441,11 @@ void cancelledSharedTransferRollsBackTheConditionalDebit() throws Exception { ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); - ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); - approval.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); - settlementWork.getAllValues().get(1).run(); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); @@ -411,6 +469,7 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme pluginField.set(target, fixture.plugin); doReturn("00000000-0000-0000-0000-000000000002").when(target).getUUID(); doReturn("Points").when(target).getPointsPath(); + doReturn(fixture.targetPlayer).when(target).getPlayer(); UserDataCache recreatedCache = mock(UserDataCache.class); doReturn(false, true).when(target).isCached(); doReturn(recreatedCache).when(target).getCache(); @@ -437,12 +496,11 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistenceWork.capture()); persistenceWork.getValue().run(); - ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); - approval.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(settlementWork.capture()); - settlementWork.getAllValues().get(1).run(); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); @@ -460,12 +518,31 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme assertEquals(Boolean.TRUE, result.get()); } + /** Runs the gate, off-thread journal claim, and Bukkit approval callback in order. */ + private static void runTransferApprovalGate(ScheduledExecutorService persistence, BukkitScheduler scheduler, + VotingPluginMain plugin, ServerImplementation entityScheduler, Player player) { + ArgumentCaptor gate = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), gate.capture()); + gate.getValue().run(); + + ArgumentCaptor claim = ArgumentCaptor.forClass(Runnable.class); + verify(persistence, org.mockito.Mockito.times(2)).execute(claim.capture()); + claim.getAllValues().get(1).run(); + + @SuppressWarnings("rawtypes") + ArgumentCaptor approval = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(entityScheduler).runAtEntityWithFallback(eq(player), approval.capture(), any(Runnable.class)); + approval.getValue().accept(null); + } + private static PointFixture pointFixture() throws Exception { PointFixture fixture = new PointFixture(); fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); fixture.persistence = mock(ScheduledExecutorService.class); fixture.scheduler = mock(BukkitScheduler.class); + fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); fixture.player = mock(Player.class); + fixture.targetPlayer = mock(Player.class); fixture.table = mock(MySQL.class); fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -498,7 +575,9 @@ private static SagaFixture sagaFixture(boolean debitSucceeds) throws Exception { fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); fixture.persistence = mock(ScheduledExecutorService.class); fixture.scheduler = mock(BukkitScheduler.class); + fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); fixture.player = mock(Player.class); + fixture.targetPlayer = mock(Player.class); fixture.table = mock(MySQL.class); fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); fixture.manager = mock(ConnectionManager.class); @@ -572,15 +651,29 @@ private static SagaFixture sagaFixture(boolean debitSucceeds) throws Exception { fixture.target = mock(VotingPluginUser.class); when(fixture.target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); when(fixture.target.getPointsPath()).thenReturn("Points"); + when(fixture.target.getPlayer()).thenReturn(fixture.targetPlayer); return fixture; } + private static ServerImplementation configureEntityScheduler(VotingPluginMain plugin, BukkitScheduler scheduler) { + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + return entityScheduler; + } + private static TransferSchedulingFixture transferSchedulingFixture() throws Exception { TransferSchedulingFixture fixture = new TransferSchedulingFixture(); fixture.plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); fixture.persistence = mock(ScheduledExecutorService.class); fixture.scheduler = mock(BukkitScheduler.class); + fixture.entityScheduler = configureEntityScheduler(fixture.plugin, fixture.scheduler); fixture.player = mock(Player.class); + fixture.targetPlayer = mock(Player.class); fixture.table = mock(MySQL.class); fixture.sql = mock(com.bencodez.simpleapi.sql.mysql.MySQL.class); fixture.manager = mock(ConnectionManager.class); @@ -652,6 +745,7 @@ private static TransferSchedulingFixture transferSchedulingFixture() throws Exce when(fixture.target.getUUID()).thenReturn("00000000-0000-0000-0000-000000000002"); when(fixture.target.getPointsPath()).thenReturn("Points"); when(fixture.target.isCached()).thenReturn(false); + when(fixture.target.getPlayer()).thenReturn(fixture.targetPlayer); return fixture; } @@ -673,7 +767,9 @@ private static final class PointFixture { VotingPluginMain plugin; ScheduledExecutorService persistence; BukkitScheduler scheduler; + ServerImplementation entityScheduler; Player player; + Player targetPlayer; MySQL table; com.bencodez.simpleapi.sql.mysql.MySQL sql; Connection connection; @@ -685,7 +781,9 @@ private static final class SagaFixture { VotingPluginMain plugin; ScheduledExecutorService persistence; BukkitScheduler scheduler; + ServerImplementation entityScheduler; Player player; + Player targetPlayer; MySQL table; com.bencodez.simpleapi.sql.mysql.MySQL sql; ConnectionManager manager; @@ -707,7 +805,9 @@ private static final class TransferSchedulingFixture { VotingPluginMain plugin; ScheduledExecutorService persistence; BukkitScheduler scheduler; + ServerImplementation entityScheduler; Player player; + Player targetPlayer; MySQL table; com.bencodez.simpleapi.sql.mysql.MySQL sql; ConnectionManager manager; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 87fc540d5..2bcbae615 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -165,6 +165,7 @@ void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); assertTrue(journal.refundUnstartedReward("scheduler-rejected")); + verify(fixture.work, org.mockito.Mockito.times(2)).setAutoCommit(false); verify(terminal).setString(1, "REFUNDED"); } @@ -289,6 +290,84 @@ void ambiguousClaimUpdateIsConfirmedBeforeRewardMayRun() throws Exception { verify(confirmation).prepareStatement(anyString()); } + @Test + void ambiguousRefundCommitIsConfirmedBeforeReturningSuccess() throws Exception { + Fixture fixture = fixture(); + Connection refundConnection = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement selectRefund = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + PreparedStatement selectConfirmation = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + ResultSet refunded = mock(ResultSet.class); + when(refundConnection.prepareStatement(anyString())).thenReturn(selectRefund, credit, terminal); + when(selectRefund.executeQuery()).thenReturn(pending); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(confirmation.prepareStatement(anyString())).thenReturn(selectConfirmation); + when(selectConfirmation.executeQuery()).thenReturn(refunded); + when(refunded.next()).thenReturn(true); + when(refunded.getString(1)).thenReturn("REFUNDED"); + AtomicBoolean refundClosed = new AtomicBoolean(); + org.mockito.Mockito.doAnswer(ignored -> { + refundClosed.set(true); + return null; + }).when(refundConnection).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(refundConnection).thenAnswer(ignored -> { + assertTrue(refundClosed.get(), "The ambiguous refund handle must be released before confirmation"); + return confirmation; + }); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(refundConnection).commit(); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("purchase-ambiguous-refund", 100L)); + + verify(refundConnection, atLeastOnce()).close(); + verify(confirmation).prepareStatement(anyString()); + } + + @Test + void unstartedRewardRetryRecognizesAnAlreadyCommittedRefund() throws Exception { + Fixture fixture = fixture(); + Connection mark = mock(Connection.class); + Connection refund = mock(Connection.class); + Connection failedConfirmation = mock(Connection.class); + Connection retryMark = mock(Connection.class); + Connection finalConfirmation = mock(Connection.class); + PreparedStatement markStatement = mock(PreparedStatement.class); + PreparedStatement refundSelect = mock(PreparedStatement.class); + PreparedStatement credit = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + PreparedStatement failedSelect = mock(PreparedStatement.class); + PreparedStatement retryMarkStatement = mock(PreparedStatement.class); + PreparedStatement finalSelect = mock(PreparedStatement.class); + ResultSet compensating = pendingRow("COMPENSATING"); + ResultSet refunded = mock(ResultSet.class); + when(mark.prepareStatement(anyString())).thenReturn(markStatement); + when(markStatement.executeUpdate()).thenReturn(1); + when(refund.prepareStatement(anyString())).thenReturn(refundSelect, credit, terminal); + when(refundSelect.executeQuery()).thenReturn(compensating); + when(credit.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(refund).commit(); + when(failedConfirmation.prepareStatement(anyString())).thenReturn(failedSelect); + doThrow(new java.sql.SQLException("confirmation unavailable")).when(failedSelect).executeQuery(); + when(retryMark.prepareStatement(anyString())).thenReturn(retryMarkStatement); + when(retryMarkStatement.executeUpdate()).thenReturn(0); + when(finalConfirmation.prepareStatement(anyString())).thenReturn(finalSelect); + when(finalSelect.executeQuery()).thenReturn(refunded); + when(refunded.next()).thenReturn(true); + when(refunded.getString(1)).thenReturn("REFUNDED"); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(mark, refund, failedConfirmation, + retryMark, finalConfirmation); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundUnstartedReward("already-refunded")); + + verify(finalSelect).executeQuery(); + } + private static Fixture fixture() throws Exception { Fixture fixture = new Fixture(); fixture.table = mock(MySQL.class); From bdd471bd79d98b3d4c9ec15a230e81994e11d872 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:02:40 -0600 Subject: [PATCH 23/74] Preserve shared point mutation state --- .../votingplugin/commands/CommandLoader.java | 5 +- .../user/SharedMysqlPointMutator.java | 27 ++++++++-- .../service/VoteShopPurchaseResult.java | 1 + .../service/VoteShopPurchaseService.java | 7 +-- .../user/SharedMysqlPointMutatorTest.java | 5 +- .../VotingPluginUserPointSchedulingTest.java | 51 +++++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 17 +++++++ 7 files changed, 102 insertions(+), 11 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 039456585..a7bc09264 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -2493,7 +2493,10 @@ public void execute(CommandSender sender, String[] args) { canonicalUser.getData().setInt("MonthTotal", month); canonicalUser.getData().setInt("WeeklyTotal", week); canonicalUser.getData().setInt("DailyTotal", day); - canonicalUser.getData().setInt("Points", points); + // Point writes must use VotingPlugin's shared-storage mutator so this + // repair cannot enqueue an absolute cached write that later overwrites + // an atomic update from another server. + new VotingPluginUser(plugin, canonicalUser).setPoints(points); // Rebuild LastVotes string if (!lastVotes.isEmpty()) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 2d953b5b8..7d2da3d5a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -459,7 +459,7 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat logFailure(failure); return false; } finally { - discardCache(user); + discardPointsCache(user); } } @@ -500,7 +500,7 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { logFailure(failure); return new AddResult(updateCommitted, user.getPoints()); } finally { - discardCache(user); + discardPointsCache(user); } } @@ -520,7 +520,7 @@ private void setAbsolute(VotingPluginUser user, int value) { } catch (SQLException failure) { logFailure(failure); } finally { - discardCache(user); + discardPointsCache(user); } } @@ -539,7 +539,7 @@ private void capAt(VotingPluginUser user, int maximum) { } catch (SQLException failure) { logFailure(failure); } finally { - discardCache(user); + discardPointsCache(user); } } @@ -556,6 +556,25 @@ private void discardCache(VotingPluginUser user) { } } + /** + * Removes only the value made stale by a direct shared-MySQL point mutation. + * The cache can be recreated while JDBC is in progress by vote processing on + * the Bukkit lane; dropping that whole cache would also lose unrelated queued + * streak, milestone, or cooldown updates. VotingPlugin routes every supported + * Points writer through this mutator; a later generic UserData Points change is + * deliberately not discarded here because it is a distinct, later write and + * the generic absolute-value API cannot provide cross-server atomic semantics. + */ + private void discardPointsCache(VotingPluginUser user) { + if (!user.isCached()) return; + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + var values = cache.getCache(); + if (values != null) values.remove(user.getPointsPath()); + } + } + private void completeOnBukkit(VotingPluginUser source, Consumer completion, boolean transferred) { plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), source.getPlayer()); } 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 fd581cab3..d24202f9a 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,7 @@ public enum VoteShopPurchaseResult { SUCCESS, + PENDING, 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 f6f1b3add..2c60f1209 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 @@ -177,8 +177,9 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, /** * Compatibility entry point for integrations compiled against the synchronous - * API. For shared MySQL, success means the durable asynchronous purchase was - * accepted; use the callback overload when the final result is required. + * 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)} */ @@ -188,7 +189,7 @@ public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, Vot VoteShopPurchaseResult validation = validateStaticPurchase(player, item); if (validation != VoteShopPurchaseResult.SUCCESS) return validation; purchase(player, user, item, ignored -> { }); - return VoteShopPurchaseResult.SUCCESS; + return VoteShopPurchaseResult.PENDING; } private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, VoteShopItem item, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 3447decfb..f6e0e3e33 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -88,7 +88,7 @@ void removeReportsARejectedConditionalDebit() throws Exception { } @Test - void pointMutationDiscardsCacheRecreatedDuringDatabaseWrite() throws Exception { + 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); @@ -110,8 +110,7 @@ void pointMutationDiscardsCacheRecreatedDuringDatabaseWrite() throws Exception { assertTrue(new SharedMysqlPointMutator(plugin).remove(user, 10)); verify(statement).executeUpdate(); - verify(plugin.getUserManager().getDataManager()).removeCache( - java.util.UUID.fromString(user.getUUID()), null); + verify(plugin.getUserManager().getDataManager(), never()).removeCache(any(), any()); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 1ef9ae734..c9901c4b3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -20,6 +20,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.HashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicReference; @@ -189,6 +190,24 @@ void sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() verify(fixture.user, never()).getPoints(); } + @Test + void storageAwareSharedAddQueuesJdbcOffTheCallingLane() 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); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + + assertEquals(15, fixture.user.addPointsStorageAware(5)); + } + + verify(fixture.persistence).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + @Test void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { PointFixture fixture = pointFixture(); @@ -200,6 +219,38 @@ void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { verify(fixture.statement).executeUpdate(); } + @Test + void sharedAbsoluteSetUsesTheDirectMysqlMutator() throws Exception { + PointFixture fixture = pointFixture(); + UserData userData = mock(UserData.class); + doReturn(userData).when(fixture.user).getUserData(); + + fixture.user.setPoints(42); + + verify(fixture.statement).setInt(1, 42); + verify(fixture.statement).executeUpdate(); + verify(userData, never()).setInt(anyString(), eq(42), eq(false)); + } + + @Test + void sharedPointMutationInvalidatesOnlyPointsFromACacheRecreatedDuringJdbc() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.statement.executeUpdate()).thenReturn(1); + UserDataCache recreatedCache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + values.put("VoteStreak", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + when(recreatedCache.getCache()).thenReturn(values); + doReturn(false, true).when(fixture.user).isCached(); + doReturn(recreatedCache).when(fixture.user).getCache(); + + assertTrue(fixture.user.removePoints(10)); + + assertFalse(values.containsKey("Points")); + assertTrue(values.containsKey("VoteStreak")); + verify(fixture.plugin.getUserManager().getDataManager(), never()).removeCache(any(), any()); + } + @Test void sharedAsyncRemoveKeepsJdbcOffTheCallerThread() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 2a1b50548..ed64e698d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -368,6 +368,23 @@ void sharedMysqlPurchaseQueuesDatabaseWorkOffCallingThread() throws Exception { verify(sql.getConnectionManager(), never()).getConnection(); } + @Test + void legacySharedMysqlPurchaseReportsPendingUntilDebitCompletes() { + MySQL table = mock(MySQL.class); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + + VoteShopPurchaseResult result = new VoteShopPurchaseService(plugin, definition) + .purchase(mock(org.bukkit.entity.Player.class), purchaseUser(), item); + + assertEquals(VoteShopPurchaseResult.PENDING, result); + verify(persistenceExecutor).execute(any(Runnable.class)); + } + @Test void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Exception { MySQL table = mock(MySQL.class); From a52a1ec77133f4edbbf0bbc32eb84f52f94cabb5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:17:18 -0600 Subject: [PATCH 24/74] Keep rejected transfer recovery off Bukkit --- .../user/SharedMysqlPointMutator.java | 12 ++++++-- .../VotingPluginUserPointSchedulingTest.java | 28 +++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 7d2da3d5a..31b2f436d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -245,8 +245,10 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target plugin.getTimer().execute(() -> claimTransferForApproval(source, target, debitAmount, creditAmountProvider, completion, journal, transferId, owner, sourcePoints, targetPoints)); } catch (RuntimeException schedulingFailure) { - refundReservedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, debitAmount, - schedulingFailure); + // This callback is on Bukkit's lane. The durable RESERVED row is + // intentionally left for the bounded periodic/startup recovery instead + // of running its JDBC refund inline after executor rejection. + completeRejectedPersistenceSubmission(source, completion, schedulingFailure); } }); } catch (RuntimeException schedulingFailure) { @@ -362,6 +364,12 @@ private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consu completeOnBukkit(source, completion, false); } + void completeRejectedPersistenceSubmission(VotingPluginUser source, Consumer completion, + RuntimeException failure) { + plugin.debug(failure); + completeOnBukkit(source, completion, false); + } + private void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index c9901c4b3..28c2695e2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -12,8 +12,10 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doAnswer; +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; @@ -23,6 +25,7 @@ import java.util.HashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicReference; import org.bukkit.entity.Player; @@ -362,6 +365,31 @@ void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws assertEquals(Boolean.FALSE, result.get()); } + @Test + void rejectedPersistenceClaimLeavesReservedTransferForOffThreadRecovery() throws Exception { + SagaFixture fixture = sagaFixture(true); + 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()); + doThrow(new RejectedExecutionException("full")).when(fixture.persistence).execute(any(Runnable.class)); + gate.getValue().run(); + + // The gate runs on Bukkit's lane. A rejected persistence submission must + // not synchronously acquire JDBC to refund; the durable RESERVED row is + // recovered by the existing off-thread periodic/startup recovery. + verifyNoInteractions(fixture.claim); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + @Test void offlineTargetApprovalFallsBackToTheOnlineSourceEntityLane() throws Exception { SagaFixture fixture = sagaFixture(true); From 6fe7db7908501dc29bc18f09d8f397b9a580ae49 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:27:59 -0600 Subject: [PATCH 25/74] Reconcile caches after recovery refunds --- .../user/SharedMysqlCacheReconciler.java | 35 +++++++++++++ .../user/SharedMysqlPointMutator.java | 13 +++-- .../user/SharedPointTransferJournal.java | 23 ++++++--- .../service/SharedMysqlPurchaseJournal.java | 49 +++++++++++++++---- .../service/VoteShopPurchaseService.java | 12 ++++- .../user/SharedMysqlPointMutatorTest.java | 22 +++++++++ .../user/SharedPointTransferJournalTest.java | 5 +- .../SharedMysqlPurchaseJournalTest.java | 6 ++- 8 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java new file mode 100644 index 000000000..4333c8e43 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -0,0 +1,35 @@ +package com.bencodez.votingplugin.user; + +import java.util.UUID; + +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.votingplugin.VotingPluginMain; + +/** Invalidates only shared-MySQL fields changed by an off-thread recovery refund. */ +public final class SharedMysqlCacheReconciler { + private SharedMysqlCacheReconciler() { + } + + /** + * Invalidates an existing cache without creating one or doing JDBC work. The + * recovery worker has already committed the refund before this method runs. + */ + public static void invalidate(VotingPluginMain plugin, String uuid, String... columns) { + if (plugin == null || uuid == null || columns == null || columns.length == 0) return; + final UUID playerUuid; + try { + playerUuid = UUID.fromString(uuid); + } catch (IllegalArgumentException invalidUuid) { + plugin.debug(invalidUuid); + return; + } + UserDataCache cache = plugin.getUserManager().getDataManager().getUserDataCache().get(playerUuid); + if (cache == null) return; + synchronized (cache) { + if (cache.getCache() == null) return; + for (String column : columns) { + if (column != null) cache.getCache().remove(column); + } + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 31b2f436d..378b9c1ab 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -48,7 +48,7 @@ static void scheduleTransferRecovery(VotingPluginMain plugin) { private static void recoverTransfers(VotingPluginMain plugin) { if (!usesSharedMysqlPoints(plugin)) return; try { - SharedPointTransferJournal.forTable(plugin.getMysql()).recoverAndCleanup(System.currentTimeMillis()); + recoverTransfers(plugin, SharedPointTransferJournal.forTable(plugin.getMysql())); } catch (SQLException failure) { plugin.getLogger().severe("Unable to recover shared MySQL point transfers: " + failure.getClass().getSimpleName()); @@ -56,6 +56,13 @@ private static void recoverTransfers(VotingPluginMain plugin) { } } + private static void recoverTransfers(VotingPluginMain plugin, SharedPointTransferJournal journal) + throws SQLException { + for (SharedPointTransferJournal.RefundedTransfer refund : journal.recoverAndCleanup(System.currentTimeMillis())) { + SharedMysqlCacheReconciler.invalidate(plugin, refund.uuid(), refund.pointsColumn()); + } + } + int add(VotingPluginUser user, int amount, boolean async) { if (async) { int predictedTotal = cachedPoints(user) + amount; @@ -140,7 +147,7 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou SharedPointTransferJournal journal = null; try { journal = SharedPointTransferJournal.forTable(table); - journal.recoverAndCleanup(System.currentTimeMillis()); + recoverTransfers(plugin, journal); try { if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, System.currentTimeMillis())) return false; @@ -217,7 +224,7 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target SharedPointTransferJournal journal; try { journal = SharedPointTransferJournal.forTable(table); - journal.recoverAndCleanup(System.currentTimeMillis()); + recoverTransfers(plugin, journal); if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, System.currentTimeMillis())) { completeOnBukkit(source, completion, false); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index 4e086fbfd..72a5e12c9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -448,6 +448,9 @@ enum SettlementOutcome { INDETERMINATE } + record RefundedTransfer(String uuid, String pointsColumn) { + } + /** * Reclaims only old reservations that have never entered an external hook, * then removes a small batch of old terminal rows. Each candidate is locked @@ -455,12 +458,15 @@ enum SettlementOutcome { * transfer that it has just claimed. HOOK_STARTED rows require explicit * reconciliation because an arbitrary listener may still have side effects. */ - void recoverAndCleanup(long now) throws SQLException { + List recoverAndCleanup(long now) throws SQLException { long reservationCutoff = now - RESERVED_RECOVERY_AGE_MILLIS; + List refunded = new ArrayList<>(); for (String transferId : findExpiredTransferIds(RESERVED, "created_at", reservationCutoff, RECOVERY_BATCH_SIZE)) { - recoverExpiredReservation(transferId, reservationCutoff); + RefundedTransfer result = recoverExpiredReservation(transferId, reservationCutoff); + if (result != null) refunded.add(result); } cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS, CLEANUP_BATCH_SIZE); + return List.copyOf(refunded); } private List findExpiredTransferIds(String state, String timeColumn, long cutoff, int limit) @@ -481,7 +487,7 @@ private List findExpiredTransferIds(String state, String timeColumn, lon return transferIds; } - private boolean recoverExpiredReservation(String transferId, long reservationCutoff) throws SQLException { + private RefundedTransfer recoverExpiredReservation(String transferId, long reservationCutoff) throws SQLException { String select = "SELECT " + qi("state") + ", " + qi("created_at") + ", " + qi("source_uuid") + ", " + qi("source_points_column") + ", " + qi("debit_points") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; @@ -497,7 +503,7 @@ private boolean recoverExpiredReservation(String transferId, long reservationCut try (ResultSet result = selectStatement.executeQuery()) { if (!result.next() || !RESERVED.equals(result.getString(1)) || result.getLong(2) > reservationCutoff) { connection.rollback(); - return false; + return null; } sourceUuid = result.getString(3); sourcePointsColumn = result.getString(4); @@ -506,7 +512,7 @@ private boolean recoverExpiredReservation(String transferId, long reservationCut } if (!isSafeColumn(sourcePointsColumn)) { connection.rollback(); - return false; + return null; } String points = qi(sourcePointsColumn); String refund = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points @@ -516,7 +522,7 @@ private boolean recoverExpiredReservation(String transferId, long reservationCut refundStatement.setString(2, sourceUuid); if (refundStatement.executeUpdate() != 1) { connection.rollback(); - return false; + return null; } } try (PreparedStatement updateStatement = connection.prepareStatement(update)) { @@ -524,10 +530,11 @@ private boolean recoverExpiredReservation(String transferId, long reservationCut updateStatement.setString(2, transferId); if (updateStatement.executeUpdate() != 1) { connection.rollback(); - return false; + return null; } } - return commitAndConfirm(connection, transferId, REFUNDED); + if (!commitAndConfirm(connection, transferId, REFUNDED)) return null; + return new RefundedTransfer(sourceUuid, sourcePointsColumn); } } 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 index 470c1d7f6..ef67f749f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -229,6 +229,10 @@ 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 @@ -277,9 +281,22 @@ private 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") + " FROM " + qiJournal() + " WHERE " @@ -291,24 +308,27 @@ private boolean setTerminal(String purchaseId, String terminalState, long now, S try (ResultSet result = selectStatement.executeQuery()) { if (!result.next()) { rollback(connection); - return false; + return null; } String state = result.getString(1); if (COMPLETED.equals(state) || REFUNDED.equals(state)) { rollback(connection); - return terminalState.equals(state); + return terminalState.equals(state) ? new RefundedPurchase(null, null, null) : null; } if (refund && !isRefundableState(state, refundableStates)) { rollback(connection); - return false; + return null; } if (!refund && !HOOK_STARTED.equals(state)) { rollback(connection); - return false; + 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 limitGenerationExpiresAt = result.getLong(7); @@ -325,10 +345,12 @@ private boolean setTerminal(String purchaseId, String terminalState, long now, S updateStatement.setString(2, purchaseId); if (updateStatement.executeUpdate() != 1) { rollback(connection); - return false; + return null; } } - return commitAndConfirm(connection, purchaseId, terminalState); + if (!commitAndConfirm(connection, purchaseId, terminalState)) return null; + return refund ? new RefundedPurchase(refundedUuid, refundedPointsColumn, refundedLimitColumn) + : new RefundedPurchase(null, null, null); } catch (SQLException failure) { throw failure; } @@ -370,7 +392,7 @@ private static boolean canRefundLimit(String generation, long expiresAt, long no return generation != null && expiresAt > 0L && now < expiresAt; } - void recoverAndCleanup(long now) throws SQLException { + 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 ?"; @@ -383,14 +405,20 @@ void recoverAndCleanup(long now) throws SQLException { while (result.next()) pending.add(result.getString(1)); } } - for (String purchaseId : pending) refundPending(purchaseId, now); + 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)) { - refundCompensatingReward(purchaseId); + 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 { @@ -495,6 +523,9 @@ private static void closeQuietly(Connection connection) { private record PurchaseRow(String state) { } + record RefundedPurchase(String uuid, String pointsColumn, String limitColumn) { + } + enum ClaimOutcome { CLAIMED, NOT_CLAIMED, 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 c95861a7b..82f159d26 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 @@ -31,6 +31,7 @@ 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.voteshop.shop.VoteShopDefinition; import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; @@ -359,7 +360,7 @@ private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { if (!usesSharedMysqlPoints(plugin)) return; try { - SharedMysqlPurchaseJournal.forTable(plugin.getMysql()).recoverAndCleanup(System.currentTimeMillis()); + recoverSharedMysqlPurchases(plugin, SharedMysqlPurchaseJournal.forTable(plugin.getMysql())); } catch (SQLException failure) { plugin.getLogger().severe("Unable to recover pending shared MySQL vote shop purchases: " + failure.getClass().getSimpleName()); @@ -367,6 +368,13 @@ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { } } + private static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal) + throws SQLException { + for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) { + SharedMysqlCacheReconciler.invalidate(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 @@ -430,7 +438,7 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo } try { SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); - journal.recoverAndCleanup(System.currentTimeMillis()); + recoverSharedMysqlPurchases(plugin, journal); String purchaseId = UUID.randomUUID().toString(); if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index f6e0e3e33..24ce767d8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -14,6 +14,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; +import java.util.HashMap; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; @@ -28,6 +29,27 @@ import com.bencodez.votingplugin.VotingPluginMain; class SharedMysqlPointMutatorTest { + @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); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java index adb3c1fb8..c52144560 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -300,12 +300,15 @@ void recoveryRefundsAnExpiredReservationUsingItsPersistedSourceColumn() throws E cleanup); SharedPointTransferJournal journal = new SharedPointTransferJournal(fixture.table); - journal.recoverAndCleanup(SharedPointTransferJournal.RESERVED_RECOVERY_AGE_MILLIS + 2L); + 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 diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 2bcbae615..7bf43229f 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -84,12 +84,16 @@ void recoveryRefundsOnlyExpiredPendingPurchase() throws Exception { when(fixture.sql.getConnectionManager().getConnection()).thenReturn(candidates, refund, compensatingCandidates, cleanup); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); - journal.recoverAndCleanup(SharedMysqlPurchaseJournal.PENDING_RECOVERY_AGE_MILLIS + 1L); + var refunded = journal.recoverAndCleanup(SharedMysqlPurchaseJournal.PENDING_RECOVERY_AGE_MILLIS + 1L); verify(credit).setInt(1, 10); verify(credit).setString(2, "player"); verify(terminal).setString(1, "REFUNDED"); verify(refund).commit(); + assertEquals(1, refunded.size()); + assertEquals("player", refunded.get(0).uuid()); + assertEquals("Points", refunded.get(0).pointsColumn()); + assertEquals("VoteShopLimitdaily", refunded.get(0).limitColumn()); } @Test From a2e011278e57ae1a32039d3d451e1796d71bb695 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 18:54:58 -0600 Subject: [PATCH 26/74] Avoid stale shared purchase cache writes --- .../user/SharedMysqlCacheReconciler.java | 9 +++-- .../user/SharedMysqlPointMutator.java | 25 ++++++++----- .../service/VoteShopPurchaseService.java | 12 ++---- .../user/SharedMysqlPointMutatorTest.java | 37 +++++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 23 ++++++++---- 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index 4333c8e43..d76f0271c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -5,14 +5,14 @@ import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.votingplugin.VotingPluginMain; -/** Invalidates only shared-MySQL fields changed by an off-thread recovery refund. */ +/** Invalidates only fields changed directly by a shared-MySQL mutation. */ public final class SharedMysqlCacheReconciler { private SharedMysqlCacheReconciler() { } /** * Invalidates an existing cache without creating one or doing JDBC work. The - * recovery worker has already committed the refund before this method runs. + * mutation has already committed before this method runs. */ public static void invalidate(VotingPluginMain plugin, String uuid, String... columns) { if (plugin == null || uuid == null || columns == null || columns.length == 0) return; @@ -26,9 +26,10 @@ public static void invalidate(VotingPluginMain plugin, String uuid, String... co UserDataCache cache = plugin.getUserManager().getDataManager().getUserDataCache().get(playerUuid); if (cache == null) return; synchronized (cache) { - if (cache.getCache() == null) return; + var values = cache.getCache(); + if (values == null) return; for (String column : columns) { - if (column != null) cache.getCache().remove(column); + if (column != null) values.remove(column); } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 378b9c1ab..7f32f27b1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -496,27 +496,32 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { + table.qi(points) + " + ? WHERE " + uuidMatch; String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch; boolean updateCommitted = false; + Integer committedTotal = null; try (Connection connection = table.getMysql().getConnectionManager().getConnection(); PreparedStatement updateStatement = connection.prepareStatement(update); PreparedStatement readStatement = connection.prepareStatement(read)) { updateStatement.setInt(1, amount); updateStatement.setString(2, user.getUUID()); - if (updateStatement.executeUpdate() != 1) return new AddResult(false, user.getPoints()); - // With JDBC auto-commit, executeUpdate returning one means the mutation - // completed. A later read may still fail after the points have been - // committed, so never turn that outcome into a retryable failure. - updateCommitted = true; - readStatement.setString(1, user.getUUID()); - try (java.sql.ResultSet result = readStatement.executeQuery()) { - return result.next() ? new AddResult(true, result.getInt(1)) - : new AddResult(updateCommitted, user.getPoints()); + if (updateStatement.executeUpdate() == 1) { + // With JDBC auto-commit, executeUpdate returning one means the mutation + // completed. A later read may still fail after the points have been + // committed, so never turn that outcome into a retryable failure. + updateCommitted = true; + readStatement.setString(1, user.getUUID()); + try (java.sql.ResultSet result = readStatement.executeQuery()) { + if (result.next()) committedTotal = result.getInt(1); + } } } catch (SQLException failure) { logFailure(failure); - return new AddResult(updateCommitted, user.getPoints()); } finally { discardPointsCache(user); } + // Do not evaluate the fallback while the JDBC handle is still held. With a + // one-connection pool, getPoints() may need that same handle after a missing + // row or a failed follow-up read. + return committedTotal == null ? new AddResult(updateCommitted, user.getPoints()) + : new AddResult(true, committedTotal); } record AddResult(boolean success, int total) {} 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 82f159d26..161731e82 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 @@ -23,7 +23,6 @@ import com.bencodez.advancedcore.api.time.TimeCalculation; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; -import com.bencodez.advancedcore.api.user.usercache.change.UserDataChangeInt; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.simpleapi.sql.DataType; @@ -518,13 +517,10 @@ private VoteShopPurchaseResult sharedMysqlFailure(VotingPluginUser user, VoteSho } private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, String limitColumn) { - if (!user.isCached()) return; - user.getCache().addChange(new UserDataChangeInt(pointsColumn, - user.getUserData().getInt(pointsColumn, UserDataFetchMode.NO_CACHE)), false); - if (limitColumn != null) { - user.getCache().addChange(new UserDataChangeInt(limitColumn, - user.getUserData().getInt(limitColumn, UserDataFetchMode.NO_CACHE)), false); - } + // 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.invalidate(plugin, user.getUUID(), pointsColumn, limitColumn); } private LimitGeneration limitGeneration(VoteShopItem item, long nowMillis) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 24ce767d8..3385f0a0b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockito.Mockito.times; @@ -265,6 +266,42 @@ void committedAddIsNotReportedRetryableWhenFollowUpReadFails() throws Exception 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 diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 00f07ec40..7ed1c8744 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -30,6 +30,8 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; +import java.util.HashMap; +import java.util.UUID; import org.bukkit.configuration.file.FileConfiguration; import org.junit.jupiter.api.Test; @@ -40,6 +42,7 @@ 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.simpleapi.sql.data.DataValue; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; @@ -295,7 +298,6 @@ void sharedMysqlDebitClosesItsConnectionBeforeRefreshingTheCache() throws Except org.mockito.Mockito.RETURNS_DEEP_STUBS); Connection connection = mock(Connection.class); PreparedStatement statement = mock(PreparedStatement.class); - UserData data = mock(UserData.class); UserDataCache cache = mock(UserDataCache.class); when(table.getTableName()).thenReturn("VotingPlugin_Users"); when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); @@ -303,21 +305,26 @@ void sharedMysqlDebitClosesItsConnectionBeforeRefreshingTheCache() throws Except when(sql.getConnectionManager().getConnection()).thenReturn(connection); when(connection.prepareStatement(anyString())).thenReturn(statement); when(statement.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); VotingPluginUser user = purchaseUser(); - when(user.isCached()).thenReturn(false, true); - when(user.getUserData()).thenReturn(data); - when(user.getCache()).thenReturn(cache); - when(data.getInt("Points", UserDataFetchMode.NO_CACHE)).thenReturn(90); + UUID userUuid = UUID.fromString(user.getUUID()); + HashMap cachedValues = new HashMap<>(); + cachedValues.put("Points", mock(DataValue.class)); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(userUuid, cache))); + when(cache.getCache()).thenReturn(cachedValues); VoteShopItem item = mock(VoteShopItem.class); when(item.getCost()).thenReturn(10); when(item.getLimit()).thenReturn(0); assertEquals(VoteShopPurchaseResult.SUCCESS, - new VoteShopPurchaseService(sharedMysqlPlugin(table), null).debitSharedMysql(user, item)); + new VoteShopPurchaseService(plugin, null).debitSharedMysql(user, item)); - InOrder closeBeforeRefresh = inOrder(connection, data); + InOrder closeBeforeRefresh = inOrder(connection, cache); closeBeforeRefresh.verify(connection).close(); - closeBeforeRefresh.verify(data).getInt("Points", UserDataFetchMode.NO_CACHE); + closeBeforeRefresh.verify(cache).getCache(); + assertFalse(cachedValues.containsKey("Points")); + verify(cache, never()).addChange(any(), org.mockito.ArgumentMatchers.anyBoolean()); } @Test From 1bfde2417dfb638aef5c9e2c4eb154bf409e7750 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 21:11:46 -0600 Subject: [PATCH 27/74] Fix shared vote shop recovery races --- .../topvoter/TopVoterHandler.java | 22 +- .../user/SharedMysqlPointMutator.java | 59 ++--- .../service/SharedMysqlPurchaseJournal.java | 236 ++++++++++++++---- .../service/VoteShopPurchaseService.java | 56 ++++- .../VotingPluginUserPointSchedulingTest.java | 48 +++- .../SharedMysqlPurchaseJournalTest.java | 166 +++++++++++- .../service/VoteShopPurchaseServiceTest.java | 83 ++++++ 7 files changed, 579 insertions(+), 91 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java index 7a5eee0ea..b60289d9b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -38,6 +38,7 @@ import com.bencodez.simpleapi.sql.DataType; import com.bencodez.votingplugin.VotingPluginMain; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseService; /** * Handles top voter rankings and statistics. @@ -235,7 +236,8 @@ public void onDayChange(DayChangeEvent event) { for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (plugin.getShopFile().getVoteShopResetDaily(shopIdent)) { - resetVoteShopLimit(shopIdent); + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); } } @@ -357,7 +359,8 @@ public void onMonthChange(MonthChangeEvent event) { for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (plugin.getShopFile().getVoteShopResetMonthly(shopIdent)) { - resetVoteShopLimit(shopIdent); + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); } } @@ -480,7 +483,8 @@ public void onWeekChange(WeekChangeEvent event) { for (String shopIdent : plugin.getShopFile().getShopIdentifiers()) { if (plugin.getShopFile().getVoteShopResetWeekly(shopIdent)) { - resetVoteShopLimit(shopIdent); + resetVoteShopLimit(shopIdent, + VoteShopPurchaseService.currentLimitGenerationId(plugin, shopIdent)); } } @@ -526,7 +530,17 @@ public void resetTotals(TopVoter topVoter) { * @param shopIdent the shop identifier */ public void resetVoteShopLimit(String shopIdent) { - plugin.getUserManager().removeAllKeyValues("VoteShopLimit" + shopIdent, DataType.INTEGER); + resetVoteShopLimit(shopIdent, null); + } + + private void resetVoteShopLimit(String shopIdent, String resetGeneration) { + String limitColumn = "VoteShopLimit" + shopIdent; + if (UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints()) { + if (resetGeneration == null) VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn); + else VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn, resetGeneration); + return; + } + plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER); } /** diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 7f32f27b1..1bf4749ec 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -177,9 +177,9 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou target.getUUID(), targetPoints, debitAmount, null); } finally { // A listener may have recreated the recipient cache while the - // settlement transaction was running. Discard it after the - // transaction without dumping stale values back to storage. - discardCache(target); + // settlement transaction was running. Invalidate only its stale + // points value without dumping it back to storage. + discardPointsCache(target, targetPoints); } logApprovalFailure(failure); return isAcceptedSettlement(outcome); @@ -194,9 +194,9 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou target.getUUID(), targetPoints, debitAmount, creditAmount); } finally { // A concurrent lookup can recreate the cache after the final - // pre-settlement drain. Never dump that stale snapshot after the - // credit commits; remove it instead. - discardCache(target); + // pre-settlement drain. Never dump its stale points snapshot after + // the credit commits; preserve unrelated cached fields. + discardPointsCache(target, targetPoints); } return isAcceptedSettlement(outcome); } catch (SQLException failure) { @@ -231,9 +231,9 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target return; } // The source cache may have been recreated while the reservation was being - // committed. Discard it after the durable debit, before any later dump can - // restore the pre-debit balance. - discardCache(source); + // committed. Invalidate its points after the durable debit, before any later + // dump can restore the pre-debit balance. + discardPointsCache(source, sourcePoints); } catch (SQLException failure) { logFailure(failure); completeOnBukkit(source, completion, false); @@ -273,7 +273,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser if (claim == SharedPointTransferJournal.ClaimOutcome.NOT_CLAIMED) { try { journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); - discardCache(source); + discardPointsCache(source, sourcePoints); } catch (SQLException failure) { logFailure(failure); } @@ -285,7 +285,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser completeOnBukkit(source, completion, true); return; } - discardCache(source); + discardPointsCache(source, sourcePoints); org.bukkit.entity.Player targetPlayer = target.getPlayer(); org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : source.getPlayer(); AtomicInteger approvalState = new AtomicInteger(0); @@ -297,7 +297,12 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser new IllegalStateException("Transfer approval task did not start"))); } catch (RuntimeException persistenceRejected) { plugin.debug(persistenceRejected); - completeOnBukkit(source, completion, false); + // The approval hook is fenced by approvalState, so an executor rejection + // can safely compensate on Bukkit's independent async scheduler without + // leaving HOOK_STARTED forever or blocking the entity lane. + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> refundClaimedAfterSchedulingFailure(source, completion, journal, transferId, + sourcePoints, debitAmount, persistenceRejected)); } }; try { @@ -346,8 +351,8 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, in outcome = journal.settleWithConfirmation(transferId, owner, source.getUUID(), sourcePoints, target.getUUID(), targetPoints, debitAmount, approvedAmount); } finally { - discardCache(source); - discardCache(target); + discardPointsCache(source, sourcePoints); + discardPointsCache(target, targetPoints); } transferred = isAcceptedSettlement(outcome); } catch (RuntimeException failure) { @@ -363,7 +368,9 @@ private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consu SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { - if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) discardCache(source); + if (journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount)) { + discardPointsCache(source, sourcePoints); + } } catch (SQLException refundFailure) { logFailure(refundFailure); } @@ -381,7 +388,9 @@ private void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consum SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { - if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) discardCache(source); + if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) { + discardPointsCache(source, sourcePoints); + } } catch (SQLException refundFailure) { logFailure(refundFailure); } @@ -570,12 +579,6 @@ private void drainCache(VotingPluginUser user) { } } - private void discardCache(VotingPluginUser user) { - if (user.isCached()) { - plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); - } - } - /** * Removes only the value made stale by a direct shared-MySQL point mutation. * The cache can be recreated while JDBC is in progress by vote processing on @@ -586,13 +589,11 @@ private void discardCache(VotingPluginUser user) { * the generic absolute-value API cannot provide cross-server atomic semantics. */ private void discardPointsCache(VotingPluginUser user) { - if (!user.isCached()) return; - UserDataCache cache = user.getCache(); - if (cache == null) return; - synchronized (cache) { - var values = cache.getCache(); - if (values != null) values.remove(user.getPointsPath()); - } + discardPointsCache(user, user.getPointsPath()); + } + + private void discardPointsCache(VotingPluginUser user, String pointsColumn) { + SharedMysqlCacheReconciler.invalidate(plugin, user.getUUID(), pointsColumn); } private void completeOnBukkit(VotingPluginUser source, Consumer completion, boolean transferred) { 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 index ef67f749f..11fa5514c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -42,7 +42,9 @@ final class SharedMysqlPurchaseJournal { /* 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<>(); @@ -50,10 +52,12 @@ final class SharedMysqlPurchaseJournal { 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(); } @@ -63,9 +67,17 @@ final class SharedMysqlPurchaseJournal { * PostgreSQL identifier limit. */ static String journalTableName(String sourceTable) { - String legacyName = sourceTable + JOURNAL_SUFFIX; + 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 HASHED_TABLE_PREFIX + hash(sourceTable + '\0' + JOURNAL_SUFFIX).substring(0, HASHED_TABLE_HEX_LENGTH); + return hashedPrefix + hash(sourceTable + '\0' + suffix).substring(0, HASHED_TABLE_HEX_LENGTH); } private static String hash(String value) { @@ -110,8 +122,8 @@ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limi 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("state") + ", " + qi("created_at") - + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + + 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(" - ?"); @@ -126,32 +138,85 @@ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limi } try (Connection connection = connection()) { connection.setAutoCommit(false); - 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); - insertStatement.setString(9, PENDING); - insertStatement.setLong(10, 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) { + try { + Long limitEpoch = tracksResetEpoch(limitColumn, limitGeneration) + ? lockLimitEpoch(connection, limitColumn) : null; + 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 false; + 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; } - return commitAndConfirm(connection, purchaseId, PENDING); } catch (SQLException failure) { rollback(connection); throw failure; @@ -299,7 +364,7 @@ private RefundedPurchase setTerminalDetails(String purchaseId, String terminalSt 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") + " FROM " + qiJournal() + " WHERE " + + qi("limit_generation_expires_at") + ", " + qi("limit_epoch") + " FROM " + qiJournal() + " WHERE " + qi("purchase_id") + " = ? FOR UPDATE"; try (Connection connection = connection()) { connection.setAutoCommit(false); @@ -331,10 +396,10 @@ private RefundedPurchase setTerminalDetails(String purchaseId, String terminalSt refundedLimitColumn = limitColumn; int cost = result.getInt(5); String limitGeneration = result.getString(6); - long limitGenerationExpiresAt = result.getLong(7); + Long limitEpoch = nullableLong(result, 8); if (refund) { - refund(connection, uuid, pointsColumn, limitColumn, cost, limitGeneration, - limitGenerationExpiresAt, now); + refund(connection, uuid, pointsColumn, limitColumn, cost, + shouldRefundLimit(connection, limitColumn, limitGeneration, limitEpoch)); } } } @@ -364,16 +429,13 @@ private static boolean isRefundableState(String state, String... refundableState } private void refund(Connection connection, String uuid, String pointsColumn, String limitColumn, int cost, - String limitGeneration, long limitGenerationExpiresAt, long now) throws SQLException { + 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(" + ?"); - // Once an item has crossed its recorded reset boundary, this is an old - // generation. Restore the charged points but never decrement a count that - // may belong to a new daily/weekly/monthly window. - if (limitColumn != null && canRefundLimit(limitGeneration, limitGenerationExpiresAt, now)) { + if (refundLimit) { refund.append(", ").append(qi(limitColumn)).append(" = GREATEST(COALESCE(").append(qi(limitColumn)) .append(", 0) - 1, 0)"); } @@ -385,11 +447,16 @@ private void refund(Connection connection, String uuid, String pointsColumn, Str } } - private static boolean canRefundLimit(String generation, long expiresAt, long now) { - if (NO_LIMIT_RESET_GENERATION.equals(generation)) return true; - // A row created before generation metadata existed cannot safely identify the - // current reset window, so preserve the newer count conservatively. - return generation != null && expiresAt > 0L && now < expiresAt; + 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 { @@ -465,13 +532,16 @@ private void ensureSchema() throws SQLException { + " 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("state") + + " 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") + ");"; @@ -483,6 +553,26 @@ private void ensureSchema() throws SQLException { } } + 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)) { @@ -497,9 +587,69 @@ private Connection connection() throws SQLException { } 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 static boolean tracksResetEpoch(String limitColumn, String generation) { + return limitColumn != null && generation != null && !NO_LIMIT_RESET_GENERATION.equals(generation); + } + + 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) { return column != null && column.matches("[A-Za-z][A-Za-z0-9_-]{0,127}"); } 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 161731e82..a02d79192 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 @@ -287,8 +287,11 @@ private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user, plugin.getTimer().execute(compensation); } catch (RuntimeException schedulingFailure) { plugin.debug(schedulingFailure); - plugin.getBukkitScheduler().runTask(plugin, - () -> completion.accept(VoteShopPurchaseResult.FAILED), player); + // The row may already be HOOK_STARTED even though the guarded reward + // callback was rejected. Do not leave that state permanently charged just + // because the persistence executor is concurrently shutting down. Bukkit's + // independent async scheduler also keeps JDBC off the entity lane. + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); } } @@ -355,6 +358,28 @@ private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { && !plugin.getBungeeSettings().isPerServerPoints(); } + /** + * 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; + 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); + } + } + /** Runs bounded stale-purchase recovery from the plugin lifecycle executor. */ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { if (!usesSharedMysqlPoints(plugin)) return; @@ -525,15 +550,24 @@ private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, St private LimitGeneration limitGeneration(VoteShopItem item, long nowMillis) { if (item.getLimit() <= 0) return LimitGeneration.NONE; - String identifier = item.getIdentifier(); + 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); return limitGeneration(plugin.getTimeChecker().getTime(), nowMillis, daily, weekly, monthly, - plugin.getOptions().getTimeWeekOffSet(), configuredTimeZone(), plugin.getOptions().getTimeHourOffSet()); + plugin.getOptions().getTimeWeekOffSet(), configuredTimeZone(plugin), + plugin.getOptions().getTimeHourOffSet()); } - private ZoneId configuredTimeZone() { + private static ZoneId configuredTimeZone(VotingPluginMain plugin) { String configured = plugin.getOptions().getTimeZone(); if (configured == null || configured.isEmpty()) return ZoneId.systemDefault(); try { @@ -565,10 +599,7 @@ private static LimitGeneration limitGeneration(LocalDateTime current, long nowMi } if (next == null || weekBoundary.isBefore(next)) next = weekBoundary; if (generation.length() > 0) generation.append('|'); - LocalDateTime weekTime = current.plusDays(weekOffset); - WeekFields fields = WeekFields.of(Locale.getDefault()); - generation.append("W:").append(weekTime.get(fields.weekBasedYear())).append('-') - .append(weekTime.get(fields.weekOfWeekBasedYear())); + generation.append(weeklyGenerationId(current, weekOffset)); } if (monthly) { LocalDateTime monthBoundary = current.toLocalDate().withDayOfMonth(1).plusMonths(1).atStartOfDay(); @@ -581,6 +612,13 @@ private static LimitGeneration limitGeneration(LocalDateTime current, long nowMi return new LimitGeneration(generation.toString(), expiresAt); } + static String weeklyGenerationId(LocalDateTime current, int weekOffset) { + LocalDateTime weekTime = current.plusDays(weekOffset).toLocalDate() + .with(java.time.temporal.TemporalAdjusters.nextOrSame(java.time.DayOfWeek.MONDAY)).atStartOfDay(); + WeekFields fields = WeekFields.ISO; + return "W:" + weekTime.get(fields.weekBasedYear()) + '-' + weekTime.get(fields.weekOfWeekBasedYear()); + } + record SharedPurchaseDebit(VoteShopPurchaseResult result, SharedMysqlPurchaseJournal journal, String purchaseId, String pointsColumn, String limitColumn) { } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 28c2695e2..f900a8fa7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -41,7 +41,6 @@ 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.advancedcore.api.user.usercache.UserDataManager; import com.bencodez.simpleapi.sql.mysql.ConnectionManager; import com.bencodez.simpleapi.scheduler.BukkitScheduler; import com.bencodez.simpleapi.folialib.FoliaLib; @@ -246,6 +245,9 @@ void sharedPointMutationInvalidatesOnlyPointsFromACacheRecreatedDuringJdbc() thr when(recreatedCache.getCache()).thenReturn(values); doReturn(false, true).when(fixture.user).isCached(); doReturn(recreatedCache).when(fixture.user).getCache(); + java.util.UUID userUuid = java.util.UUID.fromString(fixture.user.getUUID()); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(userUuid, recreatedCache))); assertTrue(fixture.user.removePoints(10)); @@ -365,6 +367,37 @@ void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws assertEquals(Boolean.FALSE, result.get()); } + @Test + void rejectedClaimedTransferCompensationUsesAsyncFallback() throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + 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 claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + claimed.getAllValues().get(1).run(); + + ArgumentCaptor asyncRefund = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), asyncRefund.capture()); + verify(fixture.settlementPoint, never()).executeUpdate(); + asyncRefund.getValue().run(); + verify(fixture.settlementPoint).setInt(1, 10); + verify(fixture.settlementPoint).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + @Test void rejectedPersistenceClaimLeavesReservedTransferForOffThreadRecovery() throws Exception { SagaFixture fixture = sagaFixture(true); @@ -552,6 +585,13 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme UserDataCache recreatedCache = mock(UserDataCache.class); doReturn(false, true).when(target).isCached(); doReturn(recreatedCache).when(target).getCache(); + java.util.HashMap recreatedValues = new java.util.HashMap<>(); + recreatedValues.put("Points", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + recreatedValues.put("DailyTotal", mock(com.bencodez.simpleapi.sql.data.DataValue.class)); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000002"), recreatedCache))); UserData targetData = mock(UserData.class); doReturn(targetData).when(target).getUserData(); doAnswer(invocation -> { @@ -592,8 +632,10 @@ void sharedTransferClosesReservationBeforeListenerDatabaseReadAndSettlesAdjustme order.verify(fixture.listenerRead).close(); order.verify(recreatedCache).dump(); order.verify(fixture.settlement).commit(); - order.verify((UserDataManager) fixture.plugin.getUserManager().getDataManager()).removeCache( - java.util.UUID.fromString("00000000-0000-0000-0000-000000000002"), null); + order.verify(recreatedCache).getCache(); + assertFalse(recreatedValues.containsKey("Points")); + assertTrue(recreatedValues.containsKey("DailyTotal"), + "settlement must preserve unrelated changes in a concurrently recreated cache"); assertEquals(Boolean.TRUE, result.get()); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 7bf43229f..2ed6f1a90 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -33,6 +33,9 @@ void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() { assertTrue(SharedMysqlPurchaseJournal.journalTableName("é".repeat(30)).matches("vp_vsp_[0-9a-f]{32}")); assertEquals("VotingPlugin_Users_VoteShopPurchases", SharedMysqlPurchaseJournal.journalTableName("VotingPlugin_Users")); + assertTrue(SharedMysqlPurchaseJournal.epochTableName(source).matches("vp_vse_[0-9a-f]{32}")); + assertEquals("VotingPlugin_Users_VoteShopLimitEpochs", + SharedMysqlPurchaseJournal.epochTableName("VotingPlugin_Users")); } @Test @@ -48,7 +51,8 @@ void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION, 0L, 100L)); verify(insert).setString(7, SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); - verify(insert).setString(9, "PENDING"); + verify(insert).setNull(9, java.sql.Types.BIGINT); + verify(insert).setString(10, "PENDING"); verify(debit).setInt(1, 10); verify(fixture.work).commit(); } @@ -109,14 +113,14 @@ void hookStartedPurchaseIsNeverRefundedByCompensation() throws Exception { } @Test - void staleRefundRestoresPointsWithoutDecrementingANewerLimitGeneration() throws Exception { + void legacyRefundRestoresPointsWithoutDecrementingAResettableLimit() throws Exception { Fixture fixture = fixture(); PreparedStatement select = mock(PreparedStatement.class); PreparedStatement refund = mock(PreparedStatement.class); PreparedStatement terminal = mock(PreparedStatement.class); ResultSet pending = pendingRow(); when(pending.getString(6)).thenReturn("D:2026-09-08"); - when(pending.getLong(7)).thenReturn(100L); + when(pending.getLong(7)).thenReturn(Long.MAX_VALUE); when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); when(select.executeQuery()).thenReturn(pending); when(refund.executeUpdate()).thenReturn(1); @@ -131,6 +135,162 @@ void staleRefundRestoresPointsWithoutDecrementingANewerLimitGeneration() throws assertFalse(sql.getAllValues().get(1).contains("`VoteShopLimitdaily` = GREATEST")); } + @Test + void reservationLocksTheCurrentEpochBeforeItsConditionalDebit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(7L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(debit.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, insert, debit); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.reserve("epoch-purchase", "player", "Points", "VoteShopLimitdaily", 10, 1, + "D:2026-09-08", 100L, 10L)); + + verify(insert).setLong(9, 7L); + org.mockito.InOrder lockBeforeDebit = org.mockito.Mockito.inOrder(markerSelect, debit); + lockBeforeDebit.verify(markerSelect).executeQuery(); + lockBeforeDebit.verify(debit).executeUpdate(); + } + + @Test + void epochMismatchRefundRestoresPointsWithoutTouchingTheNewLimit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement epochSelect = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + ResultSet currentEpoch = mock(ResultSet.class); + when(pending.getObject(8)).thenReturn(4L); + when(currentEpoch.next()).thenReturn(true); + when(currentEpoch.getLong(1)).thenReturn(5L); + when(select.executeQuery()).thenReturn(pending); + when(epochSelect.executeQuery()).thenReturn(currentEpoch); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, epochSelect, refund, terminal); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + assertTrue(journal.refundPending("old-epoch", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(sql.capture()); + assertFalse(sql.getAllValues().get(2).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void matchingEpochRefundReleasesTheReservedLimit() throws Exception { + Fixture fixture = fixture(); + PreparedStatement select = mock(PreparedStatement.class); + PreparedStatement epochSelect = mock(PreparedStatement.class); + PreparedStatement refund = mock(PreparedStatement.class); + PreparedStatement terminal = mock(PreparedStatement.class); + ResultSet pending = pendingRow(); + ResultSet currentEpoch = mock(ResultSet.class); + when(pending.getObject(8)).thenReturn(5L); + when(currentEpoch.next()).thenReturn(true); + when(currentEpoch.getLong(1)).thenReturn(5L); + when(select.executeQuery()).thenReturn(pending); + when(epochSelect.executeQuery()).thenReturn(currentEpoch); + when(refund.executeUpdate()).thenReturn(1); + when(terminal.executeUpdate()).thenReturn(1); + when(fixture.work.prepareStatement(anyString())).thenReturn(select, epochSelect, refund, terminal); + + assertTrue(new SharedMysqlPurchaseJournal(fixture.table, false).refundPending("current-epoch", 100L)); + + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().get(2).contains("`VoteShopLimitdaily` = GREATEST")); + } + + @Test + void resetRollsBackWhenItsEpochAdvanceDoesNotAffectTheMarker() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(2L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(0); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + + SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); + org.junit.jupiter.api.Assertions.assertThrows(java.sql.SQLException.class, + () -> journal.resetLimit("VoteShopLimitdaily", "D:2026-09-08")); + + verify(fixture.work).rollback(); + verify(fixture.work, org.mockito.Mockito.never()).commit(); + } + + @Test + void repeatedResetGenerationDoesNotWipeNewPeriodPurchases() throws Exception { + Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(3L); + when(epoch.getString(2)).thenReturn("D:2026-09-08"); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect); + + new SharedMysqlPurchaseJournal(fixture.table, false).resetLimit( + "VoteShopLimitdaily", "D:2026-09-08"); + + verify(fixture.work, org.mockito.Mockito.times(2)).prepareStatement(anyString()); + verify(fixture.work).rollback(); + verify(fixture.work, org.mockito.Mockito.never()).commit(); + } + + @Test + void ambiguousResetCommitIsConfirmedAfterTheConnectionIsReleased() throws Exception { + Fixture fixture = fixture(); + Connection reset = mock(Connection.class); + Connection confirmation = mock(Connection.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + PreparedStatement confirm = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + ResultSet confirmedEpoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(2L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + when(reset.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + when(confirmedEpoch.next()).thenReturn(true); + when(confirmedEpoch.getLong(1)).thenReturn(3L); + when(confirmedEpoch.getString(2)).thenReturn("D:2026-09-08"); + when(confirm.executeQuery()).thenReturn(confirmedEpoch); + when(confirmation.prepareStatement(anyString())).thenReturn(confirm); + AtomicBoolean resetClosed = new AtomicBoolean(); + doThrow(new java.sql.SQLException("commit acknowledgement lost")).when(reset).commit(); + org.mockito.Mockito.doAnswer(ignored -> { + resetClosed.set(true); + return null; + }).when(reset).close(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(reset).thenAnswer(ignored -> { + assertTrue(resetClosed.get()); + return confirmation; + }); + + new SharedMysqlPurchaseJournal(fixture.table, false).resetLimit("VoteShopLimitdaily", "D:2026-09-08"); + + verify(reset, atLeastOnce()).close(); + verify(confirm).executeQuery(); + } + @Test void refundStillReleasesALimitThatHasNoConfiguredReset() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 7ed1c8744..5e9ed349d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -75,6 +75,57 @@ void limitGenerationUsesTheEarliestConfiguredResetBoundary() { assertEquals(now + 43_200_000L, generation.expiresAt()); } + @Test + void weeklyGenerationUsesANetworkWideCalendarConvention() { + LocalDateTime saturday = LocalDateTime.of(2026, 9, 5, 12, 0); + LocalDateTime sunday = saturday.plusDays(1); + LocalDateTime monday = sunday.plusDays(1); + assertEquals("W:2026-37", VoteShopPurchaseService.weeklyGenerationId(saturday, 0)); + assertEquals("W:2026-37", VoteShopPurchaseService.weeklyGenerationId(sunday, 0)); + assertEquals("W:2026-37", VoteShopPurchaseService.weeklyGenerationId(monday, 0)); + assertEquals("W:2026-38", VoteShopPurchaseService.weeklyGenerationId(saturday.plusWeeks(1), 0)); + } + + @Test + void sharedMysqlResetUsesTheJournalEpochTransaction() 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 schemaConnection = mock(Connection.class); + Connection resetConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement generation = mock(PreparedStatement.class); + PreparedStatement generationExpiry = mock(PreparedStatement.class); + PreparedStatement epochColumn = mock(PreparedStatement.class); + PreparedStatement epochTable = mock(PreparedStatement.class); + PreparedStatement epochGeneration = mock(PreparedStatement.class); + PreparedStatement index = mock(PreparedStatement.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(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(schemaConnection, resetConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, generation, generationExpiry, + epochColumn, epochTable, epochGeneration, index); + when(resetConnection.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(11L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + + VoteShopPurchaseService.resetSharedMysqlLimit(sharedMysqlPlugin(table), "VoteShopLimitdaily"); + + verify(table).checkColumn("VoteShopLimitdaily", com.bencodez.simpleapi.sql.DataType.INTEGER); + verify(resetConnection).commit(); + ArgumentCaptor sqlText = ArgumentCaptor.forClass(String.class); + verify(resetConnection, times(4)).prepareStatement(sqlText.capture()); + assertTrue(sqlText.getAllValues().get(2).contains("`VoteShopLimitdaily` = 0")); + } + @Test void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); @@ -261,6 +312,38 @@ void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exce verify(rewardHandler, never()).giveReward(any(), any(), any(), any()); } + @Test + void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) + .when(persistenceExecutor).execute(any(Runnable.class)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( + mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + + ArgumentCaptor asyncRefund = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskAsynchronously(eq(plugin), asyncRefund.capture()); + verify(journal, never()).refundUnstartedReward(anyString()); + asyncRefund.getValue().run(); + verify(journal).refundUnstartedReward("purchase-1"); + } + @Test void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { MySQL table = mock(MySQL.class); From a58168c8486cde8b34e8b4822b66162356826fb1 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:01:39 -0600 Subject: [PATCH 28/74] Close shared vote shop recovery races --- .../commands/gui/player/VoteShopConfirm.java | 11 +++- .../user/SharedMysqlCacheReconciler.java | 31 ++++++++++ .../user/SharedMysqlPointMutator.java | 34 ++++++++++- .../user/SharedPointTransferJournal.java | 58 +++++++++++++++---- .../votingplugin/user/VotingPluginUser.java | 24 ++++++-- .../service/VoteShopPurchaseService.java | 3 + .../gui/player/VoteShopConfirmTest.java | 16 +++++ .../user/SharedPointTransferJournalTest.java | 53 +++++++++++++++++ .../VotingPluginUserPointSchedulingTest.java | 44 ++++++++++++++ .../VotingPluginUserVoteShopLimitTest.java | 39 +++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 23 +++++++- 11 files changed, 314 insertions(+), 22 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirmTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java index 1784eba26..b585c3c4d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.commands.gui.player; import java.util.ArrayList; +import java.util.concurrent.atomic.AtomicBoolean; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -31,6 +32,8 @@ public class VoteShopConfirm extends GUIHandler { private VotingPluginUser user; + private final AtomicBoolean purchaseSubmitted = new AtomicBoolean(); + /** * Creates the GUI. * @@ -71,6 +74,8 @@ public void onChest(final Player player) { @Override public void onClick(ClickEvent event) { + if (!beginPurchase()) return; + event.closeInventory(); user.cache(); plugin.getVoteShopManager().purchase(player, user, item, result -> { if (result != VoteShopPurchaseResult.SUCCESS) { @@ -102,6 +107,10 @@ public void onClick(ClickEvent event) { inv.openInventory(player); } + boolean beginPurchase() { + return purchaseSubmitted.compareAndSet(false, true); + } + @Override public void onDialog(Player player) { PlayerUtils.setPlayerMeta(plugin, player, "ident", item.getIdentifier()); @@ -114,7 +123,7 @@ public void onDialog(Player player) { .noText(new ItemBuilder(plugin.getShopFile().getShopConfirmPurchaseNoItem()).getName()) .onYes(payload -> { Player clicked = player.getServer().getPlayer(payload.owner()); - if (clicked == null) { + if (clicked == null || !beginPurchase()) { return; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index d76f0271c..4cca7b340 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.user; +import java.util.Map; import java.util.UUID; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -33,4 +34,34 @@ public static void invalidate(VotingPluginMain plugin, String uuid, String... co } } } + + /** + * Persists and detaches every live cache containing {@code column}. This must + * run before a database-wide reset so an older queued absolute value cannot + * be written after the reset transaction. + */ + public static void drainAll(VotingPluginMain plugin, String column) { + if (plugin == null || column == null) return; + var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + if (caches == null) return; + for (Map.Entry entry : Map.copyOf(caches).entrySet()) { + UserDataCache cache = entry.getValue(); + if (cache == null || !cache.isCached(column) || !caches.remove(entry.getKey(), cache)) continue; + cache.dump(); + } + } + + /** Removes a reset column from caches recreated while a shared reset ran. */ + public static void invalidateAll(VotingPluginMain plugin, String column) { + if (plugin == null || column == null) return; + var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + if (caches == null) return; + for (UserDataCache cache : caches.values()) { + if (cache == null) continue; + synchronized (cache) { + var values = cache.getCache(); + if (values != null) values.remove(column); + } + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 1bf4749ec..4dfa098d8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -291,6 +291,28 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser AtomicInteger approvalState = new AtomicInteger(0); Runnable rejectBeforeStart = () -> { if (!approvalState.compareAndSet(0, 2)) return; + try { + // The CAS fence proves the approval callback cannot run. Write the + // recoverable state before relying on either remaining scheduler. + if (!journal.markCompensating(transferId)) { + completeOnBukkit(source, completion, false); + return; + } + } catch (SQLException markerFailure) { + try { + // A lost marker acknowledgement may still have committed. The direct, + // idempotent refund accepts either HOOK_STARTED or COMPENSATING and + // avoids depending on another scheduler while shutdown is in progress. + if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) { + discardPointsCache(source, sourcePoints); + } + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + logFailure(markerFailure); + completeOnBukkit(source, completion, false); + return; + } try { plugin.getTimer().execute(() -> refundClaimedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, debitAmount, @@ -300,9 +322,15 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser // The approval hook is fenced by approvalState, so an executor rejection // can safely compensate on Bukkit's independent async scheduler without // leaving HOOK_STARTED forever or blocking the entity lane. - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, - () -> refundClaimedAfterSchedulingFailure(source, completion, journal, transferId, - sourcePoints, debitAmount, persistenceRejected)); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> refundClaimedAfterSchedulingFailure(source, completion, journal, transferId, + sourcePoints, debitAmount, persistenceRejected)); + } catch (RuntimeException asyncSchedulingRejected) { + // Recovery can compensate the durable COMPENSATING row after shutdown. + plugin.debug(asyncSchedulingRejected); + completeOnBukkit(source, completion, false); + } } }; try { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index 72a5e12c9..d4e54f8be 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -30,6 +30,12 @@ final class SharedPointTransferJournal { private static final String RESERVED = "RESERVED"; private static final String HOOK_STARTED = "HOOK_STARTED"; + /* + * The approval callback has proved it did not run while this state is + * present. It is written only after the scheduler fence rejects the callback, + * so startup/periodic recovery can compensate it immediately. + */ + private static final String COMPENSATING = "COMPENSATING"; private static final String COMPLETED = "COMPLETED"; private static final String REFUNDED = "REFUNDED"; static final long RESERVED_RECOVERY_AGE_MILLIS = TimeUnit.MINUTES.toMillis(5); @@ -250,6 +256,29 @@ enum ClaimOutcome { INDETERMINATE } + /** + * Persists a recoverable marker after the scheduler has proved that the + * Bukkit approval callback did not begin. + */ + boolean markCompensating(String transferId) throws SQLException { + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + + qi("transfer_id") + " = ? AND " + qi("state") + " IN (?, ?)"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, COMPENSATING); + updateStatement.setString(2, transferId); + updateStatement.setString(3, HOOK_STARTED); + updateStatement.setString(4, COMPENSATING); + if (updateStatement.executeUpdate() != 1) { + connection.rollback(); + return false; + } + return commitAndConfirm(connection, transferId, COMPENSATING); + } + } + } + /** * Safely releases a reservation when the hook was never claimed. A * {@code HOOK_STARTED} row is deliberately left alone because a listener may @@ -325,7 +354,7 @@ boolean refundHookStarted(String transferId, String sourceUuid, String sourcePoi connection.rollback(); return true; } - if (!HOOK_STARTED.equals(state)) { + if (!HOOK_STARTED.equals(state) && !COMPENSATING.equals(state)) { connection.rollback(); return false; } @@ -452,7 +481,8 @@ record RefundedTransfer(String uuid, String pointsColumn) { } /** - * Reclaims only old reservations that have never entered an external hook, + * Reclaims old reservations and compensation markers that have never entered + * an external hook, * then removes a small batch of old terminal rows. Each candidate is locked * and checked again before a refund, so another server cannot compensate a * transfer that it has just claimed. HOOK_STARTED rows require explicit @@ -461,23 +491,27 @@ record RefundedTransfer(String uuid, String pointsColumn) { List recoverAndCleanup(long now) throws SQLException { long reservationCutoff = now - RESERVED_RECOVERY_AGE_MILLIS; List refunded = new ArrayList<>(); - for (String transferId : findExpiredTransferIds(RESERVED, "created_at", reservationCutoff, RECOVERY_BATCH_SIZE)) { - RefundedTransfer result = recoverExpiredReservation(transferId, reservationCutoff); + for (String transferId : findExpiredRecoverableTransferIds(reservationCutoff, RECOVERY_BATCH_SIZE)) { + RefundedTransfer result = recoverRecoverableTransfer(transferId, reservationCutoff); if (result != null) refunded.add(result); } cleanupTerminalRows(now - TERMINAL_RETENTION_MILLIS, CLEANUP_BATCH_SIZE); return List.copyOf(refunded); } - private List findExpiredTransferIds(String state, String timeColumn, long cutoff, int limit) + private List findExpiredRecoverableTransferIds(long cutoff, int limit) throws SQLException { - String sql = "SELECT " + qi("transfer_id") + " FROM " + qiJournal() + " WHERE " + qi("state") - + " = ? AND " + qi(timeColumn) + " <= ? ORDER BY " + qi(timeColumn) + " ASC LIMIT ?"; + String sql = "SELECT " + qi("transfer_id") + " FROM " + qiJournal() + " WHERE (" + qi("state") + + " = ? AND " + qi("created_at") + " <= ?) OR " + qi("state") + " = ? ORDER BY " + + "CASE WHEN " + qi("state") + " = ? THEN 0 ELSE 1 END ASC, " + qi("created_at") + + " ASC LIMIT ?"; List transferIds = new ArrayList<>(); try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(sql)) { - statement.setString(1, state); + statement.setString(1, RESERVED); statement.setLong(2, cutoff); - statement.setInt(3, limit); + statement.setString(3, COMPENSATING); + statement.setString(4, COMPENSATING); + statement.setInt(5, limit); try (ResultSet result = statement.executeQuery()) { while (result.next()) { transferIds.add(result.getString(1)); @@ -487,7 +521,7 @@ private List findExpiredTransferIds(String state, String timeColumn, lon return transferIds; } - private RefundedTransfer recoverExpiredReservation(String transferId, long reservationCutoff) throws SQLException { + private RefundedTransfer recoverRecoverableTransfer(String transferId, long reservationCutoff) throws SQLException { String select = "SELECT " + qi("state") + ", " + qi("created_at") + ", " + qi("source_uuid") + ", " + qi("source_points_column") + ", " + qi("debit_points") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; @@ -501,7 +535,9 @@ private RefundedTransfer recoverExpiredReservation(String transferId, long reser try (PreparedStatement selectStatement = connection.prepareStatement(select)) { selectStatement.setString(1, transferId); try (ResultSet result = selectStatement.executeQuery()) { - if (!result.next() || !RESERVED.equals(result.getString(1)) || result.getLong(2) > reservationCutoff) { + if (!result.next() || (!RESERVED.equals(result.getString(1)) + && !COMPENSATING.equals(result.getString(1))) + || (RESERVED.equals(result.getString(1)) && result.getLong(2) > reservationCutoff)) { connection.rollback(); return null; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 50a9f4052..9d2cfdb23 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -26,7 +26,9 @@ import com.bencodez.advancedcore.api.misc.MiscUtils; import com.bencodez.advancedcore.api.rewards.RewardBuilder; import com.bencodez.advancedcore.api.rewards.RewardOptions; -import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.simpleapi.messages.MessageAPI; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; @@ -1089,8 +1091,10 @@ public int getVotePartyVotes() { * @param identifier the identifier for the vote shop * @return the vote shop identifier limit */ - public int getVoteShopIdentifierLimit(String identifier) { - return getData().getInt("VoteShopLimit" + identifier); + public int getVoteShopIdentifierLimit(String identifier) { + String path = "VoteShopLimit" + identifier; + if (usesSharedMysqlPoints()) return getData().getInt(path, UserDataFetchMode.NO_CACHE); + return getData().getInt(path); } /** @@ -1793,9 +1797,17 @@ public void setVotePartyVotes(int value) { * @param identifier the identifier for the vote shop * @param value the limit to set */ - public void setVoteShopIdentifierLimit(String identifier, int value) { - getData().setInt("VoteShopLimit" + identifier, value); - } + public void setVoteShopIdentifierLimit(String identifier, int value) { + String path = "VoteShopLimit" + identifier; + // Shared-MySQL purchase/reset transactions own these columns. Never leave an + // absolute queued cache write that another backend's reset cannot fence. + getData().setInt(path, value, !usesSharedMysqlPoints()); + } + + private boolean usesSharedMysqlPoints() { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) + && !plugin.getBungeeSettings().isPerServerPoints(); + } /** * Sets the weekly total votes. 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 a02d79192..9c135a85b 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 @@ -369,6 +369,7 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo /** 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; + SharedMysqlCacheReconciler.drainAll(plugin, limitColumn); try { MySQL table = plugin.getMysql(); table.checkColumn(limitColumn, DataType.INTEGER); @@ -377,6 +378,8 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo plugin.getLogger().severe("Unable to atomically reset shared MySQL vote shop limit: " + failure.getClass().getSimpleName()); plugin.debug(failure); + } finally { + SharedMysqlCacheReconciler.invalidateAll(plugin, limitColumn); } } 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 000000000..55ff3d340 --- /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/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java index c52144560..a9ecefd55 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -129,6 +129,23 @@ void rejectedApprovalTaskCanRefundAClaimedTransfer() throws Exception { 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(); @@ -311,6 +328,42 @@ void recoveryRefundsAnExpiredReservationUsingItsPersistedSourceColumn() throws E 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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index f900a8fa7..64c6faae8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -341,6 +341,7 @@ void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() thro @Test void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws Exception { SagaFixture fixture = sagaFixture(true); + configureRejectedSagaConnections(fixture); when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); AtomicReference result = new AtomicReference<>(); @@ -370,6 +371,7 @@ void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws @Test void rejectedClaimedTransferCompensationUsesAsyncFallback() throws Exception { SagaFixture fixture = sagaFixture(true); + configureRejectedSagaConnections(fixture); when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); AtomicReference result = new AtomicReference<>(); @@ -398,6 +400,37 @@ void rejectedClaimedTransferCompensationUsesAsyncFallback() throws Exception { assertEquals(Boolean.FALSE, result.get()); } + @Test + void rejectedClaimedTransferRetainsDurableCompensationWhenBothFallbackSchedulersReject() throws Exception { + SagaFixture fixture = sagaFixture(true); + configureRejectedSagaConnections(fixture); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + 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 claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException("disabling")).when(fixture.scheduler) + .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + claimed.getAllValues().get(1).run(); + + verify(fixture.compensationUpdate).setString(1, "COMPENSATING"); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.settlementPoint, never()).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + @Test void rejectedPersistenceClaimLeavesReservedTransferForOffThreadRecovery() throws Exception { SagaFixture fixture = sagaFixture(true); @@ -708,6 +741,7 @@ private static SagaFixture sagaFixture(boolean debitSucceeds) throws Exception { fixture.lookup = mock(Connection.class); fixture.reservation = mock(Connection.class); fixture.claim = mock(Connection.class); + fixture.compensation = mock(Connection.class); fixture.settlement = mock(Connection.class); when(fixture.plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); @@ -747,6 +781,9 @@ private static SagaFixture sagaFixture(boolean debitSucceeds) throws Exception { when(claimSelect.executeQuery()).thenReturn(reserved); when(fixture.claimUpdate.executeUpdate()).thenReturn(1); when(fixture.claim.prepareStatement(anyString())).thenReturn(claimSelect, fixture.claimUpdate); + fixture.compensationUpdate = mock(PreparedStatement.class); + when(fixture.compensationUpdate.executeUpdate()).thenReturn(1); + when(fixture.compensation.prepareStatement(anyString())).thenReturn(fixture.compensationUpdate); PreparedStatement settleSelect = mock(PreparedStatement.class); fixture.settlementPoint = mock(PreparedStatement.class); @@ -884,6 +921,11 @@ private static void configureJournalMaintenance(Connection reservedCandidates, C when(cleanupQuery.executeQuery()).thenReturn(noCleanupRows); } + private static void configureRejectedSagaConnections(SagaFixture fixture) { + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.compensation, fixture.settlement); + } + private static final class PointFixture { VotingPluginMain plugin; ScheduledExecutorService persistence; @@ -914,9 +956,11 @@ private static final class SagaFixture { Connection lookup; Connection reservation; Connection claim; + Connection compensation; Connection settlement; PreparedStatement debit; PreparedStatement claimUpdate; + PreparedStatement compensationUpdate; PreparedStatement settlementPoint; VotingPluginUser user; VotingPluginUser target; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java new file mode 100644 index 000000000..e6c3908e5 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -0,0 +1,39 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.AdvancedCoreUser; +import com.bencodez.advancedcore.api.user.UserData; +import com.bencodez.advancedcore.api.user.UserDataFetchMode; +import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.votingplugin.VotingPluginMain; + +class VotingPluginUserVoteShopLimitTest { + @Test + void sharedMysqlLimitsBypassCachedReadsAndQueuedWrites() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + UserData data = mock(UserData.class); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE)).thenReturn(3); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(data); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(base.getPlayerName()).thenReturn("Player"); + + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(data).when(user).getData(); + assertEquals(3, user.getVoteShopIdentifierLimit("daily")); + user.setVoteShopIdentifierLimit("daily", 4); + + verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + verify(data).setInt("VoteShopLimitdaily", 4, false); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 5e9ed349d..324ef385d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -105,6 +105,14 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { PreparedStatement wipe = mock(PreparedStatement.class); PreparedStatement advance = mock(PreparedStatement.class); ResultSet epoch = mock(ResultSet.class); + UserDataCache initialCache = mock(UserDataCache.class); + UserDataCache recreatedCache = mock(UserDataCache.class); + UUID cachedUuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + HashMap recreatedValues = new HashMap<>(); + recreatedValues.put("VoteShopLimitdaily", mock(DataValue.class)); + recreatedValues.put("DailyTotal", mock(DataValue.class)); + var liveCaches = new java.util.concurrent.ConcurrentHashMap(); + liveCaches.put(cachedUuid, initialCache); when(table.getTableName()).thenReturn("VotingPlugin_Users"); when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); when(table.getMysql()).thenReturn(sql); @@ -116,14 +124,27 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { when(epoch.getLong(1)).thenReturn(11L); when(markerSelect.executeQuery()).thenReturn(epoch); when(advance.executeUpdate()).thenReturn(1); + when(initialCache.isCached("VoteShopLimitdaily")).thenReturn(true); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + doAnswer(invocation -> { + liveCaches.put(cachedUuid, recreatedCache); + return 1; + }).when(wipe).executeUpdate(); + VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(liveCaches); - VoteShopPurchaseService.resetSharedMysqlLimit(sharedMysqlPlugin(table), "VoteShopLimitdaily"); + VoteShopPurchaseService.resetSharedMysqlLimit(plugin, "VoteShopLimitdaily"); verify(table).checkColumn("VoteShopLimitdaily", com.bencodez.simpleapi.sql.DataType.INTEGER); verify(resetConnection).commit(); ArgumentCaptor sqlText = ArgumentCaptor.forClass(String.class); verify(resetConnection, times(4)).prepareStatement(sqlText.capture()); assertTrue(sqlText.getAllValues().get(2).contains("`VoteShopLimitdaily` = 0")); + InOrder cacheBeforeReset = inOrder(initialCache, wipe); + cacheBeforeReset.verify(initialCache).dump(); + cacheBeforeReset.verify(wipe).executeUpdate(); + assertFalse(recreatedValues.containsKey("VoteShopLimitdaily")); + assertTrue(recreatedValues.containsKey("DailyTotal")); } @Test From cb733347a2c2c50629132ec8a8780e36c76b4ae9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:08:46 -0600 Subject: [PATCH 29/74] Bound shared point and purchase recovery work --- .../votingplugin/commands/CommandLoader.java | 75 +++++--- .../rewards/builtin/RewardPoints.java | 7 +- .../user/SharedMysqlCacheReconciler.java | 19 +- .../user/SharedMysqlPointMutator.java | 20 ++- .../votingplugin/user/VotingPluginUser.java | 163 +++++++++++++++++- .../service/SharedMysqlPurchaseJournal.java | 29 +++- .../service/VoteShopPurchaseService.java | 112 ++++++++---- .../user/SharedMysqlPointMutatorTest.java | 3 +- .../VotingPluginUserPointSchedulingTest.java | 116 +++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 83 ++++++++- 10 files changed, 523 insertions(+), 104 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index b1ee03c5d..1f222f221 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -312,22 +312,39 @@ public void executeAll(CommandSender sender, String[] args) { int num = Integer.parseInt(args[3]); sender.sendMessage(MessageAPI.colorize("&cSetting all players points to " + args[3])); + java.util.List users = new java.util.ArrayList<>(); for (String uuidStr : plugin.getUserManager().getAllUUIDs()) { UUID uuid = UUID.fromString(uuidStr); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.setPoints(num); + users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)); + } + if (users.isEmpty()) { + sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); + return; + } + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(users.size()); + java.util.concurrent.atomic.AtomicInteger updated = new java.util.concurrent.atomic.AtomicInteger(); + VotingPluginUser.setPointsStorageAware(plugin, users, num, (user, success) -> { + if (success) updated.incrementAndGet(); + if (remaining.decrementAndGet() == 0) { + sender.sendMessage(MessageAPI.colorize("&cSet all players points to " + args[3] + + " for " + updated.get() + "/" + users.size() + " players")); + plugin.getPlaceholders().onUpdate(); + } + }); } - sender.sendMessage(MessageAPI.colorize("&cDone setting all players points to " + args[3])); - plugin.getPlaceholders().onUpdate(); - } @Override public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); - user.setPoints(Integer.parseInt(args[3])); - sender.sendMessage(MessageAPI.colorize("&cSet " + args[1] + " points to " + args[3])); - plugin.getPlaceholders().onUpdate(user, false); + user.setPointsStorageAware(Integer.parseInt(args[3]), success -> { + if (!success) { + sender.sendMessage(MessageAPI.colorize("&cUnable to set " + args[1] + " points to " + args[3])); + return; + } + sender.sendMessage(MessageAPI.colorize("&cSet " + args[1] + " points to " + args[3])); + plugin.getPlaceholders().onUpdate(user, false); + }); } }); @@ -446,15 +463,17 @@ public void executeAll(CommandSender sender, String[] args) { } sender.sendMessage( MessageAPI.colorize("&cGiving " + "all players" + " " + args[3] + " points")); + java.util.List users = new java.util.ArrayList<>(); + for (String uuidStr : userIds) { + UUID uuid = UUID.fromString(uuidStr); + users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)); + } java.util.concurrent.atomic.AtomicInteger remaining = - new java.util.concurrent.atomic.AtomicInteger(userIds.size()); + new java.util.concurrent.atomic.AtomicInteger(users.size()); java.util.concurrent.atomic.AtomicInteger updated = new java.util.concurrent.atomic.AtomicInteger(); - for (String uuidStr : userIds) { - UUID uuid = UUID.fromString(uuidStr); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.addPointsStorageAware(num, (success, ignored) -> { + VotingPluginUser.addPointsStorageAware(plugin, users, num, (user, success) -> { + try { if (success) { updated.incrementAndGet(); if (user.isOnline()) { @@ -462,13 +481,14 @@ public void executeAll(CommandSender sender, String[] args) { "amount", args[3]); } } + } finally { if (remaining.decrementAndGet() == 0) { sender.sendMessage(MessageAPI.colorize("&cGave all players " + args[3] - + " points to " + updated.get() + "/" + userIds.size() + " players")); + + " points to " + updated.get() + "/" + users.size() + " players")); plugin.getPlaceholders().onUpdate(); } - }); - } + } + }); } @Override @@ -523,27 +543,30 @@ public void executeAll(CommandSender sender, String[] args) { sender.sendMessage(MessageAPI.colorize("&cNo players were available to update")); return; } - java.util.concurrent.atomic.AtomicInteger remaining = - new java.util.concurrent.atomic.AtomicInteger(userIds.size()); - java.util.concurrent.atomic.AtomicInteger removed = new java.util.concurrent.atomic.AtomicInteger(); + java.util.List users = new java.util.ArrayList<>(); for (String uuidStr : userIds) { UUID uuid = UUID.fromString(uuidStr); - VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(uuid); - user.userDataFetechMode(UserDataFetchMode.NO_CACHE); - user.removePoints(num, success -> { + users.add(plugin.getVotingPluginUserManager().getVotingPluginUser(uuid)); + } + java.util.concurrent.atomic.AtomicInteger remaining = + new java.util.concurrent.atomic.AtomicInteger(users.size()); + java.util.concurrent.atomic.AtomicInteger removed = new java.util.concurrent.atomic.AtomicInteger(); + VotingPluginUser.removePointsStorageAware(plugin, users, num, (user, success) -> { + try { if (success) { removed.incrementAndGet(); if (user.isOnline()) user.sendMessage( plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), "amount", args[3]); } + } finally { if (remaining.decrementAndGet() == 0) { sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + removed.get() + "/" + userIds.size() + " players")); plugin.getPlaceholders().onUpdate(); } - }); - } + } + }); } @Override diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index eb26a506a..4d0d36c4c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -46,9 +46,10 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - // RewardInjectInt is synchronous: later rewards and the newpoints - // placeholder must observe the committed shared-MySQL total. - String result = "" + vpUser.addPoints(num); + // Shared-MySQL arithmetic is queued on the ordered persistence lane. Its + // optimistic cached total preserves reward-chain/newpoints semantics without + // blocking vote or entity workers on JDBC. + String result = "" + vpUser.addPointsStorageAware(num); plugin.debug("Setting points to " + result); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index 4cca7b340..0dc7af831 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -1,6 +1,5 @@ package com.bencodez.votingplugin.user; -import java.util.Map; import java.util.UUID; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -35,23 +34,7 @@ public static void invalidate(VotingPluginMain plugin, String uuid, String... co } } - /** - * Persists and detaches every live cache containing {@code column}. This must - * run before a database-wide reset so an older queued absolute value cannot - * be written after the reset transaction. - */ - public static void drainAll(VotingPluginMain plugin, String column) { - if (plugin == null || column == null) return; - var caches = plugin.getUserManager().getDataManager().getUserDataCache(); - if (caches == null) return; - for (Map.Entry entry : Map.copyOf(caches).entrySet()) { - UserDataCache cache = entry.getValue(); - if (cache == null || !cache.isCached(column) || !caches.remove(entry.getKey(), cache)) continue; - cache.dump(); - } - } - - /** Removes a reset column from caches recreated while a shared reset ran. */ + /** Removes a reset column from every currently live cache without flushing it. */ public static void invalidateAll(VotingPluginMain plugin, String column) { if (plugin == null || column == null) return; var caches = plugin.getUserManager().getDataManager().getUserDataCache(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 4dfa098d8..d62fa58d7 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -16,6 +16,7 @@ import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.sql.mysql.DbType; import com.bencodez.simpleapi.sql.data.DataValue; +import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.votingplugin.VotingPluginMain; @@ -66,6 +67,7 @@ private static void recoverTransfers(VotingPluginMain plugin, SharedPointTransfe int add(VotingPluginUser user, int amount, boolean async) { if (async) { int predictedTotal = cachedPoints(user) + amount; + cachePredictedPoints(user, predictedTotal); run(() -> update(user, amount, false), true); // The mutation has not happened yet, so the historical asynchronous API // returns its predicted post-event total without blocking for storage. @@ -74,6 +76,15 @@ int add(VotingPluginUser user, int amount, boolean async) { return addAndReadCommitted(user, amount); } + private void cachePredictedPoints(VotingPluginUser user, int predictedTotal) { + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + var values = cache.getCache(); + if (values != null) values.put(user.getPointsPath(), new DataValueInt(predictedTotal)); + } + } + AddResult addCommitted(VotingPluginUser user, int amount) { return addAndReadCommittedResult(user, amount); } @@ -82,6 +93,10 @@ void set(VotingPluginUser user, int value, boolean async) { run(() -> setAbsolute(user, value), async); } + boolean setCommitted(VotingPluginUser user, int value) { + return setAbsolute(user, value); + } + void cap(VotingPluginUser user, int maximum, boolean async) { run(() -> capAt(user, maximum), async); } @@ -563,7 +578,7 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { record AddResult(boolean success, int total) {} - private void setAbsolute(VotingPluginUser user, int value) { + private boolean setAbsolute(VotingPluginUser user, int value) { drainCache(user); MySQL table = plugin.getMysql(); String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(user.getPointsPath()) @@ -573,9 +588,10 @@ private void setAbsolute(VotingPluginUser user, int value) { PreparedStatement statement = connection.prepareStatement(sql)) { statement.setInt(1, value); statement.setString(2, user.getUUID()); - statement.executeUpdate(); + return statement.executeUpdate() == 1; } catch (SQLException failure) { logFailure(failure); + return false; } finally { discardPointsCache(user); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 9d2cfdb23..75b769e98 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -49,7 +49,8 @@ * system. It extends the AdvancedCoreUser class and provides additional * functionality specific to the VotingPlugin. */ -public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { +public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { + private static final int BULK_POINT_BATCH_SIZE = 64; /** The plugin instance. */ private VotingPluginMain plugin; @@ -249,6 +250,166 @@ public void addPointsStorageAware(int value, Consumer completion) { addPointsStorageAware(value, (success, total) -> completion.accept(total)); } + /** + * Applies one shared-MySQL add operation for a collection of users on a + * single persistence task. This keeps administrative bulk commands from + * flooding the bounded persistence executor with one task per user. + * + * @param plugin plugin owning the persistence executor + * @param users users to update + * @param value points delta + * @param completion callback invoked on the Bukkit lane for every user + */ + public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, + BiConsumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + for (VotingPluginUser user : users) { + user.addPointsStorageAware(value, (success, ignored) -> completion.accept(user, success)); + } + return; + } + java.util.IdentityHashMap eventAmounts = new java.util.IdentityHashMap<>(); + for (VotingPluginUser user : users) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(user, value); + Bukkit.getPluginManager().callEvent(event); + if (!event.isCancelled()) eventAmounts.put(user, event.getPoints()); + } + bulkSharedMysqlMutation(plugin, users, completion, + (mutator, user) -> { + Integer amount = eventAmounts.get(user); + return amount != null && mutator.addCommitted(user, amount).success(); + }, + (user, done) -> done.accept(false)); + } + + /** + * Applies one shared-MySQL absolute point update for a collection of users + * on a single persistence task. + * + * @param plugin plugin owning the persistence executor + * @param users users to update + * @param value new point total + * @param completion callback invoked on the Bukkit lane for every user + */ + public static void setPointsStorageAware(VotingPluginMain plugin, List users, int value, + BiConsumer completion) { + bulkSharedMysqlMutation(plugin, users, completion, + (mutator, user) -> mutator.setCommitted(user, value), + (user, done) -> { + user.setPoints(value); + done.accept(true); + }); + } + + /** + * Sets points without performing shared-MySQL I/O on the caller thread and + * reports whether the durable update affected the user row. + * + * @param value new point total + * @param completion completion callback on the user's Bukkit/entity lane + */ + public void setPointsStorageAware(int value, Consumer completion) { + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + setPoints(value); + completion.accept(true); + return; + } + Player player = getPlayer(); + try { + plugin.getTimer().execute(() -> { + boolean updated = sharedPoints.setCommitted(this, value); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(updated), player); + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(false), player); + } + } + + /** + * Applies one shared-MySQL conditional removal for a collection of users on + * a single persistence task. + * + * @param plugin plugin owning the persistence executor + * @param users users to update + * @param value points to remove + * @param completion callback invoked on the Bukkit lane for every user + */ + public static void removePointsStorageAware(VotingPluginMain plugin, List users, int value, + BiConsumer completion) { + bulkSharedMysqlMutation(plugin, users, completion, + (mutator, user) -> mutator.remove(user, value), + (user, done) -> user.removePoints(value, done)); + } + + @FunctionalInterface + private interface SharedPointMutation { + boolean apply(SharedMysqlPointMutator mutator, VotingPluginUser user); + } + + @FunctionalInterface + private interface OrdinaryPointMutation { + void apply(VotingPluginUser user, Consumer completion); + } + + private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List users, + BiConsumer completion, SharedPointMutation sharedMutation, + OrdinaryPointMutation ordinaryMutation) { + if (users.isEmpty()) return; + if (!new SharedMysqlPointMutator(plugin).applies()) { + for (VotingPluginUser user : users) { + ordinaryMutation.apply(user, success -> completion.accept(user, success)); + } + return; + } + submitSharedMysqlChunk(plugin, users, 0, completion, sharedMutation); + } + + private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, int start, + BiConsumer completion, SharedPointMutation sharedMutation) { + int end = Math.min(start + BULK_POINT_BATCH_SIZE, users.size()); + Runnable persistenceWork = () -> { + boolean[] results = new boolean[end - start]; + SharedMysqlPointMutator mutator = new SharedMysqlPointMutator(plugin); + for (int index = start; index < end; index++) { + try { + results[index - start] = sharedMutation.apply(mutator, users.get(index)); + } catch (RuntimeException failure) { + plugin.debug(failure); + } + } + scheduleBulkCompletions(plugin, users, start, end, results, completion); + if (end < users.size()) { + submitSharedMysqlChunk(plugin, users, end, completion, sharedMutation); + } + }; + try { + plugin.getTimer().execute(persistenceWork); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + scheduleBulkCompletions(plugin, users, start, users.size(), null, completion); + } + } + + private static void scheduleBulkCompletions(VotingPluginMain plugin, List users, int start, + int end, boolean[] results, BiConsumer completion) { + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + for (int index = start; index < end; index++) { + try { + completion.accept(users.get(index), results != null && results[index - start]); + } catch (RuntimeException failure) { + plugin.debug(failure); + } + } + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + } + } + /** Adds points and reports both persistence success and the committed total. */ public synchronized void addPointsStorageAware(int value, BiConsumer completion) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); 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 index 11fa5514c..0ca76d7be 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -319,6 +319,25 @@ boolean refundUnstartedReward(String purchaseId) throws SQLException { 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") @@ -331,18 +350,12 @@ private boolean requestUnstartedRewardRefund(String purchaseId) throws SQLExcept statement.setString(4, HOOK_STARTED); statement.setString(5, COMPENSATING); if (statement.executeUpdate() != 1) return false; - try { - connection.commit(); - return true; - } catch (SQLException failure) { - rollback(connection); - throw failure; - } + return commitAndConfirm(connection, purchaseId, COMPENSATING); } } /** Retries the already-marked compensation without reopening the hook. */ - private boolean refundCompensatingReward(String purchaseId) throws SQLException { + boolean refundCompensatingReward(String purchaseId) throws SQLException { return setTerminal(purchaseId, REFUNDED, System.currentTimeMillis(), COMPENSATING); } 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 9c135a85b..3d8b59ef0 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 @@ -159,20 +159,25 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, // or entity task runs could pair an old debit with a newly loaded reward. FileConfiguration shopData = plugin.getShopFile().getData(); HashMap placeholders = purchasePlaceholders(item); - plugin.getTimer().execute(() -> { - 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) { - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit.result()), player); - return; - } - completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit); - }); + try { + plugin.getTimer().execute(() -> { + 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) { + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit.result()), player); + return; + } + completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit); + }); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + completeFailedPurchase(player, completion); + } } /** @@ -278,8 +283,27 @@ private void logClaimedRewardSchedulingFailure(SharedPurchaseDebit debit) { 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) { + plugin.getLogger().severe("Unable to mark an incomplete vote shop purchase for compensation: " + + markerFailure.getClass().getSimpleName()); + plugin.debug(markerFailure); + completeFailedPurchase(player, completion); + return; + } Runnable compensation = () -> { - refundSharedMysqlDebit(user, debit, true); + refundCompensatingMysqlDebit(user, debit); plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(VoteShopPurchaseResult.FAILED), player); }; @@ -287,11 +311,38 @@ private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user, plugin.getTimer().execute(compensation); } catch (RuntimeException schedulingFailure) { plugin.debug(schedulingFailure); - // The row may already be HOOK_STARTED even though the guarded reward - // callback was rejected. Do not leave that state permanently charged just - // because the persistence executor is concurrently shutting down. Bukkit's + // The row is already COMPENSATING even though the guarded reward callback + // was rejected. Do not leave that recoverable debit pending just because + // the persistence executor is concurrently shutting down. Bukkit's // independent async scheduler also keeps JDBC off the entity lane. - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); + } catch (RuntimeException asyncSchedulingFailure) { + // Recovery owns the already-durable COMPENSATING row after shutdown. + plugin.debug(asyncSchedulingFailure); + completeFailedPurchase(player, completion); + } + } + } + + private void completeFailedPurchase(Player player, Consumer completion) { + try { + plugin.getBukkitScheduler().runTask(plugin, + () -> completion.accept(VoteShopPurchaseResult.FAILED), player); + } 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) { + plugin.getLogger().severe("Unable to refund an incomplete vote shop purchase: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); } } @@ -369,7 +420,10 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo /** 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; - SharedMysqlCacheReconciler.drainAll(plugin, limitColumn); + // 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); @@ -519,24 +573,6 @@ private void completeSharedMysqlPurchase(SharedPurchaseDebit debit) { } } - private void refundSharedMysqlDebit(VotingPluginUser user, SharedPurchaseDebit debit, - boolean schedulerProvesRewardCannotRun) { - try { - boolean refunded = schedulerProvesRewardCannotRun - ? debit.journal().refundUnstartedReward(debit.purchaseId()) - : debit.journal().refundPending(debit.purchaseId()); - if (refunded) { - // refundPending() closes its transaction handle before any NO_CACHE - // cache refresh, including when the cache reappears concurrently. - refreshPurchaseCache(user, debit.pointsColumn(), debit.limitColumn()); - } - } catch (SQLException failure) { - plugin.getLogger().severe("Unable to refund an incomplete 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; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 3385f0a0b..1add6d945 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -188,7 +188,8 @@ void asynchronousAddUsesOnlyCachedPointsOnTheCallerThread() { assertEquals(30, new SharedMysqlPointMutator(plugin).add(user, 10, true)); - verify(cache, times(2)).getCache(); + verify(cache, times(3)).getCache(); + assertEquals(30, values.get("Points").getInt()); verify(user, never()).getPoints(); verify(persistence).execute(any(Runnable.class)); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 64c6faae8..291d293df 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -50,6 +50,102 @@ import com.bencodez.votingplugin.events.PlayerReceivePointsEvent; class VotingPluginUserPointSchedulingTest { + @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); + 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)); + + 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); + } + + @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(2)).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + @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); @@ -192,6 +288,26 @@ void sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() verify(fixture.user, never()).getPoints(); } + @Test + void consecutiveSharedAsyncAddsComposeThroughTheOptimisticCache() throws Exception { + PointFixture fixture = pointFixture(); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); + doReturn(cache).when(fixture.user).getCache(); + when(cache.getCache()).thenReturn(values); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + assertEquals(15, fixture.user.addPointsStorageAware(5)); + assertEquals(22, fixture.user.addPointsStorageAware(7)); + } + + assertEquals(22, values.get("Points").getInt()); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + @Test void storageAwareSharedAddQueuesJdbcOffTheCallingLane() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 324ef385d..076a9268d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -108,6 +108,8 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { UserDataCache initialCache = mock(UserDataCache.class); UserDataCache recreatedCache = mock(UserDataCache.class); UUID cachedUuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + HashMap initialValues = new HashMap<>(); + initialValues.put("VoteShopLimitdaily", mock(DataValue.class)); HashMap recreatedValues = new HashMap<>(); recreatedValues.put("VoteShopLimitdaily", mock(DataValue.class)); recreatedValues.put("DailyTotal", mock(DataValue.class)); @@ -124,7 +126,7 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { when(epoch.getLong(1)).thenReturn(11L); when(markerSelect.executeQuery()).thenReturn(epoch); when(advance.executeUpdate()).thenReturn(1); - when(initialCache.isCached("VoteShopLimitdaily")).thenReturn(true); + when(initialCache.getCache()).thenReturn(initialValues); when(recreatedCache.getCache()).thenReturn(recreatedValues); doAnswer(invocation -> { liveCaches.put(cachedUuid, recreatedCache); @@ -140,9 +142,8 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { ArgumentCaptor sqlText = ArgumentCaptor.forClass(String.class); verify(resetConnection, times(4)).prepareStatement(sqlText.capture()); assertTrue(sqlText.getAllValues().get(2).contains("`VoteShopLimitdaily` = 0")); - InOrder cacheBeforeReset = inOrder(initialCache, wipe); - cacheBeforeReset.verify(initialCache).dump(); - cacheBeforeReset.verify(wipe).executeUpdate(); + verify(initialCache, never()).dump(); + assertFalse(initialValues.containsKey("VoteShopLimitdaily")); assertFalse(recreatedValues.containsKey("VoteShopLimitdaily")); assertTrue(recreatedValues.containsKey("DailyTotal")); } @@ -170,6 +171,35 @@ void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { refreshBeforeValidation.verify(user).getPoints(); } + @Test + void rejectedInitialSharedMysqlSubmissionCompletesAsFailed() { + MySQL table = mock(MySQL.class); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("saturated")) + .when(persistenceExecutor).execute(any(Runnable.class)); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(player, purchaseUser(), item, result::set); + + assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + verify(persistenceExecutor).execute(any(Runnable.class)); + verify(table, never()).getMysql(); + } + @Test void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throws Exception { MySQL table = mock(MySQL.class); @@ -316,7 +346,8 @@ void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exce when(entityScheduler.runAtEntityWithFallback(any(), callback.capture(), any(Runnable.class))) .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); - when(journal.refundUnstartedReward("purchase-1")).thenReturn(false); + when(journal.markCompensating("purchase-1")).thenReturn(true); + when(journal.refundCompensatingReward("purchase-1")).thenReturn(false); VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)); @@ -327,8 +358,11 @@ void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exce ArgumentCaptor refund = ArgumentCaptor.forClass(Runnable.class); verify(persistenceExecutor).execute(refund.capture()); + InOrder markerBeforeFallback = inOrder(journal, persistenceExecutor); + markerBeforeFallback.verify(journal).markCompensating("purchase-1"); + markerBeforeFallback.verify(persistenceExecutor).execute(any(Runnable.class)); refund.getValue().run(); - verify(journal).refundUnstartedReward("purchase-1"); + verify(journal).refundCompensatingReward("purchase-1"); callback.getValue().accept(null); verify(rewardHandler, never()).giveReward(any(), any(), any(), any()); } @@ -351,6 +385,8 @@ void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) .when(persistenceExecutor).execute(any(Runnable.class)); SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + when(journal.refundCompensatingReward("purchase-1")).thenReturn(false); VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); @@ -362,7 +398,40 @@ void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { verify(scheduler).runTaskAsynchronously(eq(plugin), asyncRefund.capture()); verify(journal, never()).refundUnstartedReward(anyString()); asyncRefund.getValue().run(); - verify(journal).refundUnstartedReward("purchase-1"); + verify(journal).refundCompensatingReward("purchase-1"); + } + + @Test + void rejectedCompensationSchedulersLeaveADurableRecoveryMarker() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) + .when(persistenceExecutor).execute(any(Runnable.class)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("disabling")) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( + mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + + verify(journal).markCompensating("purchase-1"); + verify(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + verify(journal, never()).refundCompensatingReward(anyString()); } @Test From f3141f2d4fc2ddfabf7e198c80e83a9a875ebe5e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:32:35 -0600 Subject: [PATCH 30/74] Wait for shared point reward persistence --- .../rewards/builtin/RewardPoints.java | 8 ++-- .../votingplugin/user/UserManager.java | 10 ++++- .../rewards/builtin/RewardPointsTest.java | 37 +++++++++++++++++++ .../user/SharedMysqlPointMutatorTest.java | 21 +++++++++++ 4 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index 4d0d36c4c..07f54fefc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -46,10 +46,10 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - // Shared-MySQL arithmetic is queued on the ordered persistence lane. Its - // optimistic cached total preserves reward-chain/newpoints semantics without - // blocking vote or entity workers on JDBC. - String result = "" + vpUser.addPointsStorageAware(num); + // Reward injection is a synchronous chain: later rewards can consume the + // newpoints placeholder immediately. Wait for the atomic shared-MySQL update + // and committed balance rather than publishing an optimistic queued value. + String result = "" + vpUser.addPoints(num); plugin.debug("Setting points to " + result); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java index 386da8032..290d6eb55 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java @@ -36,8 +36,14 @@ public UserManager(VotingPluginMain plugin) { /** Starts the durable shared-point transfer recovery exactly once per plugin lifecycle. */ public synchronized void startSharedPointTransferRecovery() { if (sharedPointTransferRecoveryScheduled || !SharedMysqlPointMutator.usesSharedMysqlPoints(plugin)) return; - sharedPointTransferRecoveryScheduled = true; - SharedMysqlPointMutator.scheduleTransferRecovery(plugin); + try { + SharedMysqlPointMutator.scheduleTransferRecovery(plugin); + sharedPointTransferRecoveryScheduled = true; + } catch (RuntimeException rejected) { + // Shutdown can reject either the immediate recovery or its periodic task. + // Leave the lifecycle guard open so a later reload can retry scheduling. + plugin.debug(rejected); + } } /** * Adds caching keys to the user data manager. 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 000000000..0d50c8455 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java @@ -0,0 +1,37 @@ +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 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 waitsForCommittedPointTotalBeforePublishingNewpoints() { + 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.addPoints(5)).thenReturn(73); + + String result = new RewardPoints(plugin).onRewardRequest(mock(Reward.class), advancedUser, 5, + new HashMap<>()); + + assertEquals("73", result); + verify(user).addPoints(5); + verify(user, never()).addPointsStorageAware(5); + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 1add6d945..ca1dc9fc8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -17,6 +18,7 @@ import java.sql.PreparedStatement; import java.util.HashMap; import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -88,6 +90,25 @@ void userManagerSchedulesRecoveryOnceWhenReloadEnablesSharedPoints() { org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); } + @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 removeReportsARejectedConditionalDebit() throws Exception { MySQL table = mock(MySQL.class); From 35f3ac2a3552fdc4d5f101e6f21ebee3ad319082 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 05:46:29 -0600 Subject: [PATCH 31/74] Handle rejected shared point submissions --- .../user/SharedMysqlPointMutator.java | 23 +++++++--- .../votingplugin/user/VotingPluginUser.java | 27 +++++++---- .../VotingPluginUserPointSchedulingTest.java | 45 +++++++++++++++++++ 3 files changed, 81 insertions(+), 14 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index d62fa58d7..cff07356d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -66,9 +66,13 @@ private static void recoverTransfers(VotingPluginMain plugin, SharedPointTransfe int add(VotingPluginUser user, int amount, boolean async) { if (async) { - int predictedTotal = cachedPoints(user) + amount; + int previousTotal = cachedPoints(user); + int predictedTotal = previousTotal + amount; cachePredictedPoints(user, predictedTotal); - run(() -> update(user, amount, false), true); + if (!run(() -> update(user, amount, false), true)) { + discardPointsCache(user); + return previousTotal; + } // The mutation has not happened yet, so the historical asynchronous API // returns its predicted post-event total without blocking for storage. return predictedTotal; @@ -108,10 +112,10 @@ boolean remove(VotingPluginUser user, int amount) { boolean remove(VotingPluginUser user, int amount, boolean async) { if (!async) return remove(user, amount); boolean predictedSuccess = cachedPoints(user) >= amount; - run(() -> update(user, -amount, true), true); + boolean submitted = run(() -> update(user, -amount, true), true); // Preserve the historical asynchronous API contract: the caller receives // the cached prediction while the conditional database debit runs later. - return predictedSuccess; + return submitted && predictedSuccess; } private int cachedPoints(VotingPluginUser user) { @@ -498,11 +502,18 @@ private boolean transferAtomically(VotingPluginUser source, VotingPluginUser tar } } - private void run(Runnable operation, boolean async) { + private boolean run(Runnable operation, boolean async) { if (async) { - plugin.getTimer().execute(operation); + try { + plugin.getTimer().execute(operation); + return true; + } catch (RuntimeException rejected) { + plugin.debug(rejected); + return false; + } } else { operation.run(); + return true; } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 75b769e98..aef13f6a0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -426,10 +426,16 @@ public synchronized void addPointsStorageAware(int value, BiConsumer { - SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(result.success(), result.total()), player); - }); + try { + plugin.getTimer().execute(() -> { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); + plugin.getBukkitScheduler().runTask(plugin, + () -> completion.accept(result.success(), result.total()), player); + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(false, 0), player); + } } /** @@ -1551,10 +1557,15 @@ public void removePoints(int points, Consumer completion) { return; } Player player = getPlayer(); - plugin.getTimer().execute(() -> { - boolean removed = sharedPoints.remove(this, points); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(removed), player); - }); + try { + plugin.getTimer().execute(() -> { + boolean removed = sharedPoints.remove(this, points); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(removed), player); + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(false), player); + } } /** diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 291d293df..c34be899d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -326,6 +326,51 @@ void storageAwareSharedAddQueuesJdbcOffTheCallingLane() throws Exception { verify(fixture.sql.getConnectionManager(), never()).getConnection(); } + @Test + void rejectedAsyncAddDiscardsOptimisticPointsAndKeepsCallerAlive() throws Exception { + PointFixture fixture = pointFixture(); + UserDataCache cache = mock(UserDataCache.class); + HashMap values = new HashMap<>(); + values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); + doReturn(true).when(fixture.user).isCached(); + doReturn(cache).when(fixture.user).getCache(); + when(cache.getCache()).thenReturn(values); + when(fixture.plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of( + java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), cache))); + doThrow(new RejectedExecutionException("full")).when(fixture.persistence).execute(any(Runnable.class)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + assertEquals(10, fixture.user.addPointsStorageAware(5)); + } + + assertFalse(values.containsKey("Points")); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + + @Test + void rejectedSingleUserPointCallbacksCompleteAsFailures() throws Exception { + PointFixture fixture = pointFixture(); + AtomicReference addResult = new AtomicReference<>(); + AtomicReference removeResult = new AtomicReference<>(); + doThrow(new RejectedExecutionException("full")).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), eq(fixture.player)); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.addPointsStorageAware(5, (success, ignored) -> addResult.set(success)); + fixture.user.removePoints(5, removeResult::set); + } + + assertEquals(Boolean.FALSE, addResult.get()); + assertEquals(Boolean.FALSE, removeResult.get()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + } + @Test void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { PointFixture fixture = pointFixture(); From e7fb2416e20b3d7ae2006b8fd53d46b5dcea2bcc Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:19:55 -0600 Subject: [PATCH 32/74] Keep shared shop validation off GUI lanes --- .../commands/gui/player/VoteShop.java | 5 ++--- .../commands/gui/player/VoteShopConfirm.java | 4 ++-- .../service/VoteShopPurchaseService.java | 9 ++++++++ .../service/VoteShopPurchaseServiceTest.java | 22 +++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java index 7517425b4..927b12272 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java @@ -125,9 +125,8 @@ protected void addItemButton(BInventory inv, final Player player, final VotingPl @Override public void onClick(ClickEvent event) { VotingPluginUser clickedUser = getUser(event.getPlayer()); - if (plugin.getConfigFile().isExtraVoteShopCheck()) { - clickedUser.cache(); - } + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(clickedUser, + plugin.getConfigFile().isExtraVoteShopCheck()); if (item.isNotBuyable()) { clickedUser.sendMessage(plugin.getConfigFile().getFormatShopNotPurchasable()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java index b585c3c4d..060d5fa17 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java @@ -76,7 +76,7 @@ public void onChest(final Player player) { public void onClick(ClickEvent event) { if (!beginPurchase()) return; event.closeInventory(); - user.cache(); + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, true); plugin.getVoteShopManager().purchase(player, user, item, result -> { if (result != VoteShopPurchaseResult.SUCCESS) { plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result); @@ -127,7 +127,7 @@ public void onDialog(Player player) { return; } - user.cache(); + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, true); plugin.getVoteShopManager().purchase(clicked, user, item, result -> { if (result != VoteShopPurchaseResult.SUCCESS) { plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item, 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 3d8b59ef0..7838f14e2 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 @@ -78,6 +78,10 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u 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 (usesSharedMysqlPoints()) return VoteShopPurchaseResult.SUCCESS; if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { return VoteShopPurchaseResult.LIMIT_REACHED; } @@ -87,6 +91,11 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u return VoteShopPurchaseResult.SUCCESS; } + /** Refreshes dynamic GUI validation state only when that refresh cannot block on shared MySQL. */ + public void refreshUserForPurchaseValidation(VotingPluginUser user, boolean requested) { + if (requested && !usesSharedMysqlPoints()) user.cache(); + } + private VoteShopPurchaseResult validateStaticPurchase(Player player, VoteShopItem item) { if (!definition.isEnabled()) { return VoteShopPurchaseResult.SHOP_DISABLED; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 076a9268d..7f1265f3b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -171,6 +171,28 @@ void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { refreshBeforeValidation.verify(user).getPoints(); } + @Test + void sharedMysqlGuiValidationDoesNotReadOrRefreshDynamicUserState() { + VotingPluginMain plugin = sharedMysqlPlugin(mock(MySQL.class)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("daily"); + when(item.getCost()).thenReturn(10); + VotingPluginUser user = mock(VotingPluginUser.class); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, definition); + + service.refreshUserForPurchaseValidation(user, true); + VoteShopPurchaseResult result = service.validatePurchase(mock(org.bukkit.entity.Player.class), user, item); + + assertEquals(VoteShopPurchaseResult.SUCCESS, result); + verify(user, never()).cache(); + verify(user, never()).getVoteShopIdentifierLimit(anyString()); + verify(user, never()).getPoints(); + } + @Test void rejectedInitialSharedMysqlSubmissionCompletesAsFailed() { MySQL table = mock(MySQL.class); From b35abf4004bfe10b31ebf32ba031d09027962945 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 10:46:16 -0600 Subject: [PATCH 33/74] Recover rejected shared point operations --- .../user/SharedMysqlPointMutator.java | 76 ++++++++++++++++++- .../votingplugin/user/VotingPluginUser.java | 21 ++++- .../service/VoteShopPurchaseService.java | 14 ++-- .../user/SharedMysqlPointMutatorTest.java | 37 +++++++++ .../VotingPluginUserPointSchedulingTest.java | 66 ++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 10 +++ 6 files changed, 209 insertions(+), 15 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index cff07356d..a25d16b71 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -105,6 +105,15 @@ void cap(VotingPluginUser user, int maximum, boolean async) { run(() -> capAt(user, maximum), async); } + /** + * Adds points and applies the configured upper bound in one accepted + * persistence operation. This prevents executor saturation from accepting + * the addition while dropping a separately submitted cap. + */ + void addAndCap(VotingPluginUser user, int amount, int maximum, boolean async) { + run(() -> addAndCapAt(user, amount, maximum), async); + } + boolean remove(VotingPluginUser user, int amount) { return update(user, -amount, true); } @@ -232,7 +241,8 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou */ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, IntFunction creditAmountProvider, Consumer completion) { - plugin.getTimer().execute(() -> { + try { + plugin.getTimer().execute(() -> { drainCache(source); drainCache(target); MySQL table = plugin.getMysql(); @@ -281,7 +291,13 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target refundReservedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, debitAmount, schedulingFailure); } - }); + }); + } catch (RuntimeException schedulingFailure) { + // No reservation exists when the initial persistence task is rejected. + // Still complete the command contract on the source entity lane. + plugin.debug(schedulingFailure); + completeOnBukkit(source, completion, false); + } } private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, @@ -300,8 +316,11 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser return; } if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { - logIndeterminateClaim(transferId); - completeOnBukkit(source, completion, true); + // The approval task has not been submitted yet, so an ambiguous claim + // cannot have invoked the recipient hook. Compensate the durable claim + // instead of reporting success and leaving a HOOK_STARTED debit behind. + refundIndeterminateClaimBeforeApproval(source, completion, journal, transferId, sourcePoints, + debitAmount); return; } discardPointsCache(source, sourcePoints); @@ -425,6 +444,35 @@ private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consu completeOnBukkit(source, completion, false); } + private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { + boolean refunded = false; + try { + // HOOK_STARTED is safe to compensate because the approval task has not + // been submitted yet. A RESERVED row is handled by its normal refund. + if (journal.markCompensating(transferId)) { + refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + } else { + refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + } + } catch (SQLException markerFailure) { + // A lost marker acknowledgement may still have committed. Both refund + // operations are idempotent and cover either durable pre-hook state. + try { + refunded = journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); + if (!refunded) { + refunded = journal.refundReserved(transferId, source.getUUID(), sourcePoints, debitAmount); + } + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + logFailure(markerFailure); + } + if (refunded) discardPointsCache(source, sourcePoints); + if (!refunded) logIndeterminateClaim(transferId); + completeOnBukkit(source, completion, false); + } + void completeRejectedPersistenceSubmission(VotingPluginUser source, Consumer completion, RuntimeException failure) { plugin.debug(failure); @@ -627,6 +675,26 @@ private void capAt(VotingPluginUser user, int maximum) { } } + private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { + drainCache(user); + MySQL table = plugin.getMysql(); + String points = user.getPointsPath(); + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" + + table.qi(points) + " + ?, ?) WHERE " + table.qi("uuid") + + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); + try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + PreparedStatement statement = connection.prepareStatement(sql)) { + statement.setInt(1, amount); + statement.setInt(2, maximum); + statement.setString(3, user.getUUID()); + statement.executeUpdate(); + } catch (SQLException failure) { + logFailure(failure); + } finally { + discardPointsCache(user); + } + } + private void drainCache(VotingPluginUser user) { if (user.isCached()) { user.getCache().dump(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index aef13f6a0..6a39113e8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -186,17 +186,30 @@ public void addPoints() { int points = plugin.getConfigFile().getPointsOnVote(); SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); boolean sharedMysql = sharedPoints.applies(); + int limit = plugin.getConfigFile().getLimitVotePoints(); + if (sharedMysql && points != 0 && limit > 0) { + // Keep the receive hook semantics of addPoints(int, boolean), while + // accepting the addition and upper bound as one persistence task. + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points); + Bukkit.getPluginManager().callEvent(event); + if (!event.isCancelled()) { + sharedPoints.addAndCap(this, event.getPoints(), limit, true); + } else { + sharedPoints.cap(this, limit, true); + } + return; + } if (points != 0) { // Vote processing runs on the server lane. Shared MySQL arithmetic must // use the lifecycle persistence executor instead of blocking a tick on // connection acquisition and the committed-balance read. addPoints(points, sharedMysql); } - if (plugin.getConfigFile().getLimitVotePoints() > 0) { + if (limit > 0) { if (sharedMysql) { - sharedPoints.cap(this, plugin.getConfigFile().getLimitVotePoints(), true); - } else if (getPoints() > plugin.getConfigFile().getLimitVotePoints()) { - setPoints(plugin.getConfigFile().getLimitVotePoints()); + sharedPoints.cap(this, limit, true); + } else if (getPoints() > limit) { + setPoints(limit); } } } 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 7838f14e2..0636385fa 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 @@ -226,18 +226,12 @@ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, V .runAtEntityWithFallback(player, ignored -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; claimSharedMysqlPurchaseAsync(debit).whenComplete((claim, failure) -> { - if (failure != null || claim == SharedMysqlPurchaseJournal.ClaimOutcome.NOT_CLAIMED) { + if (failure != null || requiresCompensation(claim)) { if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) { compensateSharedMysqlPurchase(player, user, completion, debit); } return; } - if (claim == SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE) { - state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); - plugin.getLogger().severe("Shared MySQL vote shop purchase " + debit.purchaseId() - + " has an indeterminate reward claim; retaining it for reconciliation"); - return; - } state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); scheduleClaimedReward(player, user, item, placeholders, shopData, completion, debit); }); @@ -253,6 +247,12 @@ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, V } } + 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) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index ca1dc9fc8..c658bb1bc 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -351,6 +351,43 @@ void capUsesLeastSoItCannotRestoreAConcurrentDebit() throws Exception { 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"); + + new SharedMysqlPointMutator(plugin).addAndCap(user, 10, 100, true); + + 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(); + } + @Test void transferCreditsOnlyAfterConditionalDebitSucceeds() throws Exception { MySQL table = mock(MySQL.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index c34be899d..7f6d95d56 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -201,6 +201,72 @@ void votePointAwardQueuesSharedMysqlMutationOffTheServerLane() throws Exception verify(fixture.sql.getConnectionManager(), never()).getConnection(); } + @Test + void votePointAwardCombinesSharedAdditionAndCapInOnePersistenceTask() throws Exception { + PointFixture fixture = pointFixture(); + 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(); + } + + 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(); + + 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.transferPoints(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(Boolean.FALSE, result.get()); + } + + @Test + void indeterminateSharedTransferClaimDoesNotReportSuccessBeforeApproval() throws Exception { + SagaFixture fixture = sagaFixture(true); + 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, never()).runAtEntityWithFallback(any(), any(), any(Runnable.class)); + completion.getValue().run(); + assertEquals(Boolean.FALSE, result.get()); + } + @Test void sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 7f1265f3b..693e19ab3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -52,6 +52,16 @@ import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; class VoteShopPurchaseServiceTest { + @Test + void unconfirmedRewardClaimIsCompensatedBeforeTheRewardCanStart() { + assertTrue(VoteShopPurchaseService.requiresCompensation( + SharedMysqlPurchaseJournal.ClaimOutcome.INDETERMINATE)); + assertTrue(VoteShopPurchaseService.requiresCompensation( + SharedMysqlPurchaseJournal.ClaimOutcome.NOT_CLAIMED)); + assertFalse(VoteShopPurchaseService.requiresCompensation( + SharedMysqlPurchaseJournal.ClaimOutcome.CLAIMED)); + } + @Test void retainsSynchronousPurchaseDescriptorsForBinaryCompatibility() throws Exception { assertEquals(VoteShopPurchaseResult.class, VoteShopPurchaseService.class From 6b87b9fe02faf1176259e55878a90b986a96b391 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:04:29 -0600 Subject: [PATCH 34/74] Avoid blocking vote shop placeholder reads --- .../votingplugin/user/VotingPluginUser.java | 10 +++++-- .../VotingPluginUserVoteShopLimitTest.java | 29 +++++++++++++++++-- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 6a39113e8..3ffa9ea27 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1273,9 +1273,15 @@ public int getVotePartyVotes() { */ public int getVoteShopIdentifierLimit(String identifier) { String path = "VoteShopLimit" + identifier; - if (usesSharedMysqlPoints()) return getData().getInt(path, UserDataFetchMode.NO_CACHE); + if (usesSharedMysqlPoints()) { + // Placeholder and GUI rendering may run on the Bukkit main thread or a + // Folia-owned tick thread. This accessor therefore never performs JDBC for + // shared MySQL; purchases remain protected by their atomic reservation. + return getData().getInt(path, + isCached() ? UserDataFetchMode.CACHE_ONLY : UserDataFetchMode.TEMP_ONLY); + } return getData().getInt(path); - } + } /** * Gets the weekly total votes. diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index e6c3908e5..6859aa2f6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -17,12 +18,33 @@ class VotingPluginUserVoteShopLimitTest { @Test - void sharedMysqlLimitsBypassCachedReadsAndQueuedWrites() { + void sharedMysqlLimitsUseNonBlockingUserCacheWhenAvailable() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); UserData data = mock(UserData.class); - when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE)).thenReturn(3); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.CACHE_ONLY)).thenReturn(3); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(data); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(base.getPlayerName()).thenReturn("Player"); + + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(data).when(user).getData(); + doReturn(true).when(user).isCached(); + assertEquals(3, user.getVoteShopIdentifierLimit("daily")); + + verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.CACHE_ONLY); + verify(data, never()).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + } + + @Test + void sharedMysqlLimitsUseOnlyTemporaryDataWhenUserIsNotCached() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + UserData data = mock(UserData.class); + when(data.getInt("VoteShopLimitdaily", UserDataFetchMode.TEMP_ONLY)).thenReturn(3); AdvancedCoreUser base = mock(AdvancedCoreUser.class); when(base.getUserData()).thenReturn(data); when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); @@ -33,7 +55,8 @@ void sharedMysqlLimitsBypassCachedReadsAndQueuedWrites() { assertEquals(3, user.getVoteShopIdentifierLimit("daily")); user.setVoteShopIdentifierLimit("daily", 4); - verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); + verify(data).getInt("VoteShopLimitdaily", UserDataFetchMode.TEMP_ONLY); + verify(data, never()).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); verify(data).setInt("VoteShopLimitdaily", 4, false); } } From 87de933fab1bf1000e217aae7286a92308ef8bd8 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:19:52 -0600 Subject: [PATCH 35/74] Fix shared vote shop recovery and cache refresh --- .../user/SharedMysqlCacheReconciler.java | 33 ++++++ .../user/SharedMysqlPointMutator.java | 12 +- .../service/SharedMysqlCompensationStore.java | 107 ++++++++++++++++++ .../service/SharedMysqlPurchaseJournal.java | 4 +- .../service/VoteShopPurchaseService.java | 61 ++++++++-- .../user/SharedMysqlPointMutatorTest.java | 39 +++++++ .../VotingPluginUserPointSchedulingTest.java | 12 ++ .../service/VoteShopPurchaseServiceTest.java | 86 ++++++++++++++ 8 files changed, 345 insertions(+), 9 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index 0dc7af831..06fdac52e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -34,6 +34,17 @@ public static void invalidate(VotingPluginMain plugin, String uuid, String... co } } + /** Invalidates changed fields and schedules a nonblocking authoritative refill. */ + public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, String... columns) { + invalidate(plugin, uuid, columns); + if (plugin == null || uuid == null) return; + try { + plugin.getVotingPluginUserManager().getVotingPluginUser(UUID.fromString(uuid), false).cacheAsync(); + } catch (RuntimeException refreshFailure) { + plugin.debug(refreshFailure); + } + } + /** Removes a reset column from every currently live cache without flushing it. */ public static void invalidateAll(VotingPluginMain plugin, String column) { if (plugin == null || column == null) return; @@ -47,4 +58,26 @@ public static void invalidateAll(VotingPluginMain plugin, String column) { } } } + + /** Invalidates a shared column and repopulates live user caches asynchronously. */ + public static void invalidateAllAndRefresh(VotingPluginMain plugin, String column) { + if (plugin == null || column == null) return; + var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + if (caches == null) return; + UUID[] users = caches.keySet().toArray(UUID[]::new); + invalidateAll(plugin, column); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { + for (UUID uuid : users) { + try { + plugin.getVotingPluginUserManager().getVotingPluginUser(uuid, false).cache(); + } catch (RuntimeException refreshFailure) { + plugin.debug(refreshFailure); + } + } + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index a25d16b71..eb94be021 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -111,7 +111,17 @@ void cap(VotingPluginUser user, int maximum, boolean async) { * the addition while dropping a separately submitted cap. */ void addAndCap(VotingPluginUser user, int amount, int maximum, boolean async) { - run(() -> addAndCapAt(user, amount, maximum), async); + if (!async) { + addAndCapAt(user, amount, maximum); + return; + } + int previousTotal = cachedPoints(user); + int predictedTotal = (int) Math.max(Integer.MIN_VALUE, + Math.min((long) previousTotal + amount, maximum)); + cachePredictedPoints(user, predictedTotal); + if (!run(() -> addAndCapAt(user, amount, maximum), true)) { + discardPointsCache(user); + } } boolean remove(VotingPluginUser user, int amount) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java new file mode 100644 index 000000000..4e8de55a8 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlCompensationStore.java @@ -0,0 +1,107 @@ +package com.bencodez.votingplugin.voteshop.service; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import com.bencodez.votingplugin.util.DurableFiles; + +/** Restart-safe local proof that a rejected reward callback is safe to refund. */ +final class SharedMysqlCompensationStore { + private static final String DIRECTORY = ".voteshop-compensations"; + private static final String SUFFIX = ".pending"; + private static final int MAX_PURCHASE_ID_BYTES = 256; + private static final int RECOVERY_BATCH_SIZE = 128; + + private final Path directory; + + SharedMysqlCompensationStore(Path dataDirectory) { + this.directory = dataDirectory.toAbsolutePath().normalize().resolve(DIRECTORY); + } + + void record(String purchaseId) throws IOException { + byte[] contents = contents(purchaseId); + ensureSafeDirectory(); + Path target = marker(purchaseId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) throw unsafe(); + return; + } + Path temporary = Files.createTempFile(directory, ".compensation-", ".tmp"); + try { + Files.write(temporary, contents, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + DurableFiles.forceFile(temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, target); + } + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + List loadBatch() throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of(); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe(); + List purchases = new ArrayList<>(); + try (DirectoryStream 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 index 0ca76d7be..84b971685 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -596,7 +596,9 @@ private void ensureColumn(Connection connection, String column, String definitio } private Connection connection() throws SQLException { - return table.getMysql().getConnectionManager().getConnection(); + 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); } 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 0636385fa..8641d240d 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,5 +1,6 @@ package com.bencodez.votingplugin.voteshop.service; +import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; @@ -305,6 +306,7 @@ private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user, 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); @@ -442,7 +444,7 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo + failure.getClass().getSimpleName()); plugin.debug(failure); } finally { - SharedMysqlCacheReconciler.invalidateAll(plugin, limitColumn); + SharedMysqlCacheReconciler.invalidateAllAndRefresh(plugin, limitColumn); } } @@ -458,10 +460,12 @@ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { } } - private static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal) + static void recoverSharedMysqlPurchases(VotingPluginMain plugin, SharedMysqlPurchaseJournal journal) throws SQLException { + retryPendingCompensationMarkers(plugin, journal); for (SharedMysqlPurchaseJournal.RefundedPurchase refund : journal.recoverAndCleanup(System.currentTimeMillis())) { - SharedMysqlCacheReconciler.invalidate(plugin, refund.uuid(), refund.pointsColumn(), refund.limitColumn()); + SharedMysqlCacheReconciler.invalidateAndRefresh(plugin, refund.uuid(), refund.pointsColumn(), + refund.limitColumn()); } } @@ -490,7 +494,7 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item if (limitColumn != null) sql.append(" AND COALESCE(").append(table.qi(limitColumn)).append(", 0) < ?"); boolean debited = false; - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql.toString())) { statement.setInt(1, item.getCost()); statement.setString(2, user.getUUID()); @@ -501,7 +505,7 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item plugin.getLogger().severe("Unable to atomically debit vote shop points: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return VoteShopPurchaseResult.NOT_ENOUGH_POINTS; + return VoteShopPurchaseResult.FAILED; } if (debited) { // The conditional debit connection has been closed before NO_CACHE reads. @@ -511,6 +515,12 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item 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(); @@ -542,7 +552,7 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo plugin.getLogger().severe("Unable to atomically debit vote shop points: " + failure.getClass().getSimpleName()); plugin.debug(failure); - return new SharedPurchaseDebit(VoteShopPurchaseResult.NOT_ENOUGH_POINTS, null, null, null, null); + return new SharedPurchaseDebit(VoteShopPurchaseResult.FAILED, null, null, null, null); } return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null); } @@ -593,7 +603,44 @@ private void refreshPurchaseCache(VotingPluginUser user, String pointsColumn, St // 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.invalidate(plugin, user.getUUID(), pointsColumn, limitColumn); + 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) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index c658bb1bc..3491ec24b 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -17,6 +17,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.util.HashMap; +import java.util.UUID; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.TimeUnit; @@ -29,6 +30,7 @@ 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 { @@ -202,6 +204,7 @@ void asynchronousAddUsesOnlyCachedPointsOnTheCallerThread() { 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); @@ -370,8 +373,18 @@ void addAndCapUsesOneAtomicPersistenceMutation() throws Exception { 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); + assertEquals(100, values.get("Points").getInt()); ArgumentCaptor task = ArgumentCaptor.forClass(Runnable.class); verify(persistence).execute(task.capture()); @@ -386,6 +399,32 @@ void addAndCapUsesOneAtomicPersistenceMutation() throws Exception { 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 diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 7f6d95d56..1fdb8236d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -204,6 +204,15 @@ void votePointAwardQueuesSharedMysqlMutationOffTheServerLane() throws Exception @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); @@ -212,11 +221,13 @@ void votePointAwardCombinesSharedAdditionAndCapInOnePersistenceTask() throws Exc 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` + ?, ?)"))); @@ -361,6 +372,7 @@ void consecutiveSharedAsyncAddsComposeThroughTheOptimisticCache() throws Excepti HashMap values = new HashMap<>(); values.put("Points", new com.bencodez.simpleapi.sql.data.DataValueInt(10)); doReturn(cache).when(fixture.user).getCache(); + doReturn(true).when(fixture.user).isCached(); when(cache.getCache()).thenReturn(values); try (MockedStatic bukkit = mockStatic(Bukkit.class)) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 693e19ab3..187aa401c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.doAnswer; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; @@ -19,6 +20,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.nio.file.Path; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.concurrent.CompletableFuture; @@ -35,6 +37,7 @@ import org.bukkit.configuration.file.FileConfiguration; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.mockito.ArgumentCaptor; import org.mockito.InOrder; @@ -117,6 +120,7 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { ResultSet epoch = mock(ResultSet.class); UserDataCache initialCache = mock(UserDataCache.class); UserDataCache recreatedCache = mock(UserDataCache.class); + VotingPluginUser refreshedUser = mock(VotingPluginUser.class); UUID cachedUuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); HashMap initialValues = new HashMap<>(); initialValues.put("VoteShopLimitdaily", mock(DataValue.class)); @@ -144,6 +148,8 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { }).when(wipe).executeUpdate(); VotingPluginMain plugin = sharedMysqlPlugin(table); when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn(liveCaches); + when(plugin.getVotingPluginUserManager().getVotingPluginUser(cachedUuid, false)).thenReturn(refreshedUser); + ArgumentCaptor refill = ArgumentCaptor.forClass(Runnable.class); VoteShopPurchaseService.resetSharedMysqlLimit(plugin, "VoteShopLimitdaily"); @@ -156,6 +162,9 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { assertFalse(initialValues.containsKey("VoteShopLimitdaily")); assertFalse(recreatedValues.containsKey("VoteShopLimitdaily")); assertTrue(recreatedValues.containsKey("DailyTotal")); + verify(plugin.getBukkitScheduler()).runTaskAsynchronously(eq(plugin), refill.capture()); + refill.getValue().run(); + verify(refreshedUser).cache(); } @Test @@ -466,6 +475,49 @@ void rejectedCompensationSchedulersLeaveADurableRecoveryMarker() throws Exceptio verify(journal, never()).refundCompensatingReward(anyString()); } + @Test + void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mockPluginForCompensation(temporaryDirectory); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = plugin.getBukkitScheduler(); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + plugin.getBukkitScheduler().getFoliaLib().getImpl(); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")) + .thenThrow(new java.sql.SQLException("down")) + .thenThrow(new java.sql.SQLException("still down")) + .thenReturn(true); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( + mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + assertEquals(java.util.List.of("purchase-1"), + new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + assertEquals(java.util.List.of("purchase-1"), + new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); + assertTrue(new SharedMysqlCompensationStore(temporaryDirectory).loadBatch().isEmpty()); + + verify(journal, times(3)).markCompensating("purchase-1"); + verify(journal, times(2)).recoverAndCleanup(anyLong()); + verify(scheduler, never()).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + } + + private static VotingPluginMain mockPluginForCompensation(Path dataDirectory) { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getDataFolder()).thenReturn(dataDirectory.toFile()); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + plugin.getBukkitScheduler().getFoliaLib().getImpl(); + when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + return plugin; + } + @Test void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { MySQL table = mock(MySQL.class); @@ -588,6 +640,40 @@ void sharedMysqlPurchaseQueuesDatabaseWorkOffCallingThread() throws Exception { verify(sql.getConnectionManager(), never()).getConnection(); } + @Test + void sharedMysqlReservationDatabaseFailureReportsFailed() 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.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(null); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(player, purchaseUser(), item, result::set); + ArgumentCaptor databaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(databaseWork.capture()); + databaseWork.getValue().run(); + + assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); + } + @Test void legacySharedMysqlPurchaseReportsPendingUntilDebitCompletes() { MySQL table = mock(MySQL.class); From c687cbfe2792a0bce950a951c1292465288cf57b Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:44:37 -0600 Subject: [PATCH 36/74] Handle rejected transfer settlement and cache races --- .../user/SharedMysqlCacheReconciler.java | 36 ++++++--- .../user/SharedMysqlPointMutator.java | 19 ++++- .../user/SharedMysqlCacheReconcilerTest.java | 40 ++++++++++ .../VotingPluginUserPointSchedulingTest.java | 73 +++++++++++++++++++ 4 files changed, 154 insertions(+), 14 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconcilerTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index 06fdac52e..e217a133b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -1,6 +1,8 @@ package com.bencodez.votingplugin.user; +import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.votingplugin.VotingPluginMain; @@ -48,24 +50,24 @@ public static void invalidateAndRefresh(VotingPluginMain plugin, String uuid, St /** Removes a reset column from every currently live cache without flushing it. */ public static void invalidateAll(VotingPluginMain plugin, String column) { if (plugin == null || column == null) return; - var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + ConcurrentHashMap caches = plugin.getUserManager().getDataManager().getUserDataCache(); if (caches == null) return; - for (UserDataCache cache : caches.values()) { - if (cache == null) continue; - synchronized (cache) { - var values = cache.getCache(); - if (values != null) values.remove(column); - } + for (Map.Entry entry : snapshot(caches)) { + invalidate(entry.getValue(), column); } } /** Invalidates a shared column and repopulates live user caches asynchronously. */ public static void invalidateAllAndRefresh(VotingPluginMain plugin, String column) { if (plugin == null || column == null) return; - var caches = plugin.getUserManager().getDataManager().getUserDataCache(); + ConcurrentHashMap caches = plugin.getUserManager().getDataManager().getUserDataCache(); if (caches == null) return; - UUID[] users = caches.keySet().toArray(UUID[]::new); - invalidateAll(plugin, column); + Map.Entry[] entries = snapshot(caches); + UUID[] users = new UUID[entries.length]; + for (int i = 0; i < entries.length; i++) { + users[i] = entries[i].getKey(); + invalidate(entries[i].getValue(), column); + } try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> { for (UUID uuid : users) { @@ -80,4 +82,18 @@ public static void invalidateAllAndRefresh(VotingPluginMain plugin, String colum plugin.debug(schedulingFailure); } } + + /** Copies the registry before any cache is touched; the registry is live. */ + @SuppressWarnings("unchecked") + private static Map.Entry[] snapshot(ConcurrentHashMap caches) { + return caches.entrySet().toArray(new Map.Entry[0]); + } + + private static void invalidate(UserDataCache cache, String column) { + if (cache == null) return; + synchronized (cache) { + var values = cache.getCache(); + if (values != null) values.remove(column); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index eb94be021..a36f185ea 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -397,11 +397,22 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser plugin.getTimer().execute(() -> settleTransfer(source, target, debitAmount, completion, journal, transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); } catch (RuntimeException schedulingFailure) { - // The approval callback already ran and may have had side effects. Keep the - // claimed row for explicit reconciliation instead of refunding it. plugin.debug(schedulingFailure); - logIndeterminateClaim(transferId); - completeOnBukkit(source, completion, true); + // The approval callback already ran and may have had side effects. Submit + // the same idempotent settlement through Bukkit's independent async + // scheduler before retaining the claimed row for reconciliation. + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, + () -> settleTransfer(source, target, debitAmount, completion, journal, transferId, owner, + sourcePoints, targetPoints, finalApprovedAmount)); + } catch (RuntimeException asyncSchedulingFailure) { + // Neither scheduler accepted settlement. The hook may have had side + // effects, so preserve the durable HOOK_STARTED row for explicit + // reconciliation and suppress a duplicate transfer attempt. + plugin.debug(asyncSchedulingFailure); + logIndeterminateClaim(transferId); + completeOnBukkit(source, completion, true); + } } finally { approvalState.set(2); } 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 000000000..399f2e19d --- /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/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 1fdb8236d..6af54b547 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -577,6 +577,79 @@ void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() thro assertEquals(null, result.get()); } + @Test + void rejectedApprovalSettlementSubmissionUsesBukkitAsyncFallbackWithApprovedAmount() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + PluginManager pluginManager = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); + doAnswer(invocation -> { + invocation.getArgument(0).setPoints(4); + return null; + }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(reservation.capture()); + reservation.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(); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + @SuppressWarnings("rawtypes") + ArgumentCaptor approval = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.targetPlayer), approval.capture(), any(Runnable.class)); + approval.getValue().accept(null); + + ArgumentCaptor asyncSettlement = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), asyncSettlement.capture()); + verify(fixture.settlementPoint, never()).executeUpdate(); + asyncSettlement.getValue().run(); + verify(fixture.settlementPoint).setInt(1, 4); + verify(fixture.settlementPoint).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + assertEquals(Boolean.TRUE, result.get()); + } + + @Test + void rejectedApprovalSettlementSchedulersRetainHookStartedForReconciliation() throws Exception { + SagaFixture fixture = sagaFixture(true); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(reservation.capture()); + reservation.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(); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException("disabling")).when(fixture.scheduler) + .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + @SuppressWarnings("rawtypes") + ArgumentCaptor approval = ArgumentCaptor.forClass(java.util.function.Consumer.class); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.targetPlayer), approval.capture(), any(Runnable.class)); + approval.getValue().accept(null); + + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.settlementPoint, never()).executeUpdate(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + assertEquals(Boolean.TRUE, result.get()); + } + @Test void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws Exception { SagaFixture fixture = sagaFixture(true); From 21ac3f935237f8ccfda610681738184011bbf9f9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:27:03 -0600 Subject: [PATCH 37/74] Harden shared point settlement and callback recovery --- .../votingplugin/commands/CommandLoader.java | 60 ++++--- .../user/SharedMysqlPointMutator.java | 160 +++++++++++------- .../SharedPointTransferCompensationStore.java | 107 ++++++++++++ .../user/SharedPointTransferJournal.java | 4 +- .../votingplugin/user/VotingPluginUser.java | 35 ++-- .../util/BukkitCompletionScheduler.java | 69 ++++++++ .../service/VoteShopPurchaseResult.java | 1 + .../service/VoteShopPurchaseService.java | 93 +++++++--- .../commands/CommandLoaderSchedulingTest.java | 93 ++++++++++ .../VotingPluginUserPointSchedulingTest.java | 107 ++++++++++-- .../util/BukkitCompletionSchedulerTest.java | 85 ++++++++++ .../service/VoteShopPurchaseServiceTest.java | 137 ++++++++++++++- 12 files changed, 816 insertions(+), 135 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 1f222f221..01c82f8a0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -90,6 +90,7 @@ import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.util.VoteTaskAdmission; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult; import com.bencodez.votingplugin.voteshop.shop.VoteShopEntry; import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; @@ -117,6 +118,14 @@ public CommandLoader(VotingPluginMain plugin) { this.plugin = plugin; } + void runForCommandSender(CommandSender sender, Runnable task) { + BukkitCompletionScheduler.run(plugin, sender instanceof Player player ? player : null, task); + } + + void runForVotingUser(VotingPluginUser user, Runnable task) { + BukkitCompletionScheduler.run(plugin, user.getPlayer(), task); + } + /** * @return the adminPerm */ @@ -327,9 +336,11 @@ public void executeAll(CommandSender sender, String[] args) { VotingPluginUser.setPointsStorageAware(plugin, users, num, (user, success) -> { if (success) updated.incrementAndGet(); if (remaining.decrementAndGet() == 0) { - sender.sendMessage(MessageAPI.colorize("&cSet all players points to " + args[3] - + " for " + updated.get() + "/" + users.size() + " players")); - plugin.getPlaceholders().onUpdate(); + runForCommandSender(sender, () -> { + sender.sendMessage(MessageAPI.colorize("&cSet all players points to " + args[3] + + " for " + updated.get() + "/" + users.size() + " players")); + plugin.getPlaceholders().onUpdate(); + }); } }); } @@ -339,10 +350,12 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.setPointsStorageAware(Integer.parseInt(args[3]), success -> { if (!success) { - sender.sendMessage(MessageAPI.colorize("&cUnable to set " + args[1] + " points to " + args[3])); + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cUnable to set " + args[1] + " points to " + args[3]))); return; } - sender.sendMessage(MessageAPI.colorize("&cSet " + args[1] + " points to " + args[3])); + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cSet " + args[1] + " points to " + args[3]))); plugin.getPlaceholders().onUpdate(user, false); }); } @@ -483,9 +496,11 @@ public void executeAll(CommandSender sender, String[] args) { } } finally { if (remaining.decrementAndGet() == 0) { - sender.sendMessage(MessageAPI.colorize("&cGave all players " + args[3] - + " points to " + updated.get() + "/" + users.size() + " players")); - plugin.getPlaceholders().onUpdate(); + runForCommandSender(sender, () -> { + sender.sendMessage(MessageAPI.colorize("&cGave all players " + args[3] + + " points to " + updated.get() + "/" + users.size() + " players")); + plugin.getPlaceholders().onUpdate(); + }); } } }); @@ -498,15 +513,16 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { int amount = Integer.parseInt(args[3]); user.addPointsStorageAware(amount, (success, newTotal) -> { if (!success) { - sender.sendMessage(MessageAPI.colorize("&cUnable to add " + args[3] + " points to " + args[1])); + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cUnable to add " + args[3] + " points to " + args[1]))); return; } if (user.isOnline()) { user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), "amount", args[3]); } - sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + " " + args[3] + " points" + ", " - + args[1] + " now has " + newTotal + " points")); + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + + " " + args[3] + " points" + ", " + args[1] + " now has " + newTotal + " points"))); plugin.getPlaceholders().onUpdate(user, false); }); @@ -561,9 +577,11 @@ public void executeAll(CommandSender sender, String[] args) { } } finally { if (remaining.decrementAndGet() == 0) { - sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " - + removed.get() + "/" + userIds.size() + " players")); - plugin.getPlaceholders().onUpdate(); + runForCommandSender(sender, () -> { + sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + + removed.get() + "/" + userIds.size() + " players")); + plugin.getPlaceholders().onUpdate(); + }); } } }); @@ -575,13 +593,14 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { user.cache(); user.removePoints(Integer.parseInt(args[3]), removed -> { if (!removed) { - sender.sendMessage(MessageAPI.colorize("&cUnable to remove " + args[3] + " points from " - + args[1])); + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( + "&cUnable to remove " + args[3] + " points from " + args[1]))); return; } if (user.isOnline()) user.sendMessage( plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerRemoved(), "amount", args[3]); - sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " + args[1])); + runForCommandSender(sender, () -> sender.sendMessage( + MessageAPI.colorize("&cRemoved " + args[3] + " points from " + args[1]))); plugin.getPlaceholders().onUpdate(user, false); }); } @@ -3778,9 +3797,10 @@ public void execute(CommandSender sender, String[] args) { plugin.getConfigFile() .getFormatCommandsVoteGivePointsTransferFrom(), placeholders)); - user.sendMessage(PlaceholderUtils.replacePlaceHolder( - plugin.getConfigFile().getFormatCommandsVoteGivePointsTransferTo(), - placeholders)); + runForVotingUser(user, + () -> user.sendMessage(PlaceholderUtils.replacePlaceHolder(plugin + .getConfigFile().getFormatCommandsVoteGivePointsTransferTo(), + placeholders))); } else { sendMessage(sender, plugin.getConfigFile() .getFormatCommandsVoteGivePointsNotEnoughPoints()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index a36f185ea..44c1c3445 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -1,5 +1,6 @@ package com.bencodez.votingplugin.user; +import java.io.IOException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; @@ -19,6 +20,7 @@ import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; /** Performs point writes that must remain atomic across shared MySQL servers. */ final class SharedMysqlPointMutator { @@ -57,8 +59,9 @@ private static void recoverTransfers(VotingPluginMain plugin) { } } - private static void recoverTransfers(VotingPluginMain plugin, SharedPointTransferJournal journal) + static void recoverTransfers(VotingPluginMain plugin, SharedPointTransferJournal journal) throws SQLException { + retryPendingCompensationMarkers(plugin, journal); for (SharedPointTransferJournal.RefundedTransfer refund : journal.recoverAndCleanup(System.currentTimeMillis())) { SharedMysqlCacheReconciler.invalidate(plugin, refund.uuid(), refund.pointsColumn()); } @@ -339,47 +342,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser AtomicInteger approvalState = new AtomicInteger(0); Runnable rejectBeforeStart = () -> { if (!approvalState.compareAndSet(0, 2)) return; - try { - // The CAS fence proves the approval callback cannot run. Write the - // recoverable state before relying on either remaining scheduler. - if (!journal.markCompensating(transferId)) { - completeOnBukkit(source, completion, false); - return; - } - } catch (SQLException markerFailure) { - try { - // A lost marker acknowledgement may still have committed. The direct, - // idempotent refund accepts either HOOK_STARTED or COMPENSATING and - // avoids depending on another scheduler while shutdown is in progress. - if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) { - discardPointsCache(source, sourcePoints); - } - } catch (SQLException refundFailure) { - logFailure(refundFailure); - } - logFailure(markerFailure); - completeOnBukkit(source, completion, false); - return; - } - try { - plugin.getTimer().execute(() -> refundClaimedAfterSchedulingFailure(source, completion, journal, - transferId, sourcePoints, debitAmount, - new IllegalStateException("Transfer approval task did not start"))); - } catch (RuntimeException persistenceRejected) { - plugin.debug(persistenceRejected); - // The approval hook is fenced by approvalState, so an executor rejection - // can safely compensate on Bukkit's independent async scheduler without - // leaving HOOK_STARTED forever or blocking the entity lane. - try { - plugin.getBukkitScheduler().runTaskAsynchronously(plugin, - () -> refundClaimedAfterSchedulingFailure(source, completion, journal, transferId, - sourcePoints, debitAmount, persistenceRejected)); - } catch (RuntimeException asyncSchedulingRejected) { - // Recovery can compensate the durable COMPENSATING row after shutdown. - plugin.debug(asyncSchedulingRejected); - completeOnBukkit(source, completion, false); - } - } + scheduleRejectedTransferCompensation(source, completion, journal, transferId, sourcePoints, debitAmount); }; try { CompletableFuture approval = plugin.getBukkitScheduler().getFoliaLib().getImpl() @@ -398,17 +361,11 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); } catch (RuntimeException schedulingFailure) { plugin.debug(schedulingFailure); - // The approval callback already ran and may have had side effects. Submit - // the same idempotent settlement through Bukkit's independent async - // scheduler before retaining the claimed row for reconciliation. try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, () -> settleTransfer(source, target, debitAmount, completion, journal, transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); } catch (RuntimeException asyncSchedulingFailure) { - // Neither scheduler accepted settlement. The hook may have had side - // effects, so preserve the durable HOOK_STARTED row for explicit - // reconciliation and suppress a duplicate transfer attempt. plugin.debug(asyncSchedulingFailure); logIndeterminateClaim(transferId); completeOnBukkit(source, completion, true); @@ -426,6 +383,85 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser } } + private void scheduleRejectedTransferCompensation(VotingPluginUser source, Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { + Runnable compensation = () -> compensateRejectedTransfer(source, completion, journal, transferId, + sourcePoints, debitAmount); + try { + plugin.getTimer().execute(compensation); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + rememberPendingCompensationMarker(plugin, transferId); + completeOnBukkit(source, completion, false); + } + } + } + + private void rememberPendingCompensationMarker(VotingPluginMain plugin, String transferId) { + try { + compensationStore(plugin).record(transferId); + } catch (IOException persistenceFailure) { + plugin.getLogger().severe("Unable to persist a shared point transfer compensation marker: " + + persistenceFailure.getClass().getSimpleName()); + plugin.debug(persistenceFailure); + } + } + + private static void retryPendingCompensationMarkers(VotingPluginMain plugin, + SharedPointTransferJournal journal) { + SharedPointTransferCompensationStore store = compensationStore(plugin); + final java.util.List pending; + try { + pending = store.loadBatch(); + } catch (IOException loadFailure) { + plugin.debug(loadFailure); + return; + } + for (String transferId : pending) { + try { + journal.markCompensating(transferId); + store.remove(transferId); + } catch (SQLException retryFailure) { + plugin.debug(retryFailure); + } catch (IOException removalFailure) { + plugin.debug(removalFailure); + } + } + } + + private static SharedPointTransferCompensationStore compensationStore(VotingPluginMain plugin) { + return new SharedPointTransferCompensationStore(plugin.getDataFolder().toPath()); + } + + private void compensateRejectedTransfer(VotingPluginUser source, Consumer completion, + SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { + try { + // The CAS fence proves the approval callback cannot run. Write the + // recoverable state before relying on completion delivery. + if (!journal.markCompensating(transferId)) { + completeOnBukkit(source, completion, false); + return; + } + } catch (SQLException markerFailure) { + try { + if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) { + discardPointsCache(source, sourcePoints); + } + } catch (SQLException refundFailure) { + logFailure(refundFailure); + } + logFailure(markerFailure); + completeOnBukkit(source, completion, false); + return; + } + refundClaimedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, + debitAmount, new IllegalStateException("Transfer approval task did not start")); + } + private void settleTransfer(VotingPluginUser source, VotingPluginUser target, int debitAmount, Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints, Integer approvedAmount) { @@ -542,7 +578,7 @@ private boolean transferAtomically(VotingPluginUser source, VotingPluginUser tar + table.qi(sourcePoints) + " - ? WHERE " + uuidMatch + " AND " + table.qi(sourcePoints) + " >= ?"; String credit = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(targetPoints) + " = " + table.qi(targetPoints) + " + ? WHERE " + uuidMatch; - try (Connection connection = table.getMysql().getConnectionManager().getConnection()) { + try (Connection connection = requireConnection(table)) { connection.setAutoCommit(false); try (PreparedStatement debitStatement = connection.prepareStatement(debit); PreparedStatement creditStatement = connection.prepareStatement(credit)) { @@ -596,7 +632,7 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat if (requireNonnegative) { sql.append(" AND ").append(table.qi(points)).append(" >= ?"); } - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql.toString())) { statement.setInt(1, delta); statement.setString(2, user.getUUID()); @@ -629,7 +665,7 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch; boolean updateCommitted = false; Integer committedTotal = null; - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + try (Connection connection = requireConnection(table); PreparedStatement updateStatement = connection.prepareStatement(update); PreparedStatement readStatement = connection.prepareStatement(read)) { updateStatement.setInt(1, amount); @@ -652,8 +688,12 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { // Do not evaluate the fallback while the JDBC handle is still held. With a // one-connection pool, getPoints() may need that same handle after a missing // row or a failed follow-up read. - return committedTotal == null ? new AddResult(updateCommitted, user.getPoints()) - : new AddResult(true, committedTotal); + if (committedTotal != null) return new AddResult(true, committedTotal); + // A failed acquisition cannot support the fallback read either. Report the + // mutation failure without checking out a second connection and let callers + // complete their callback deterministically. + if (!updateCommitted) return new AddResult(false, 0); + return new AddResult(true, user.getPoints()); } record AddResult(boolean success, int total) {} @@ -664,7 +704,7 @@ private boolean setAbsolute(VotingPluginUser user, int value) { String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(user.getPointsPath()) + " = ? WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setInt(1, value); statement.setString(2, user.getUUID()); @@ -684,7 +724,7 @@ private void capAt(VotingPluginUser user, int maximum) { String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" + table.qi(points) + ", ?) WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setInt(1, maximum); statement.setString(2, user.getUUID()); @@ -703,7 +743,7 @@ private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" + table.qi(points) + " + ?, ?) WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); - try (Connection connection = table.getMysql().getConnectionManager().getConnection(); + try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql)) { statement.setInt(1, amount); statement.setInt(2, maximum); @@ -723,6 +763,12 @@ private void drainCache(VotingPluginUser user) { } } + 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; + } + /** * Removes only the value made stale by a direct shared-MySQL point mutation. * The cache can be recreated while JDBC is in progress by vote processing on @@ -741,7 +787,7 @@ private void discardPointsCache(VotingPluginUser user, String pointsColumn) { } private void completeOnBukkit(VotingPluginUser source, Consumer completion, boolean transferred) { - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(transferred), source.getPlayer()); + BukkitCompletionScheduler.run(plugin, source.getPlayer(), () -> completion.accept(transferred)); } private void logFailure(SQLException failure) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java new file mode 100644 index 000000000..b5af9e0ea --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferCompensationStore.java @@ -0,0 +1,107 @@ +package com.bencodez.votingplugin.user; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; + +import com.bencodez.votingplugin.util.DurableFiles; + +/** Restart-safe local proof that a rejected transfer callback is safe to refund. */ +final class SharedPointTransferCompensationStore { + private static final String DIRECTORY = ".point-transfer-compensations"; + private static final String SUFFIX = ".pending"; + private static final int MAX_TRANSFER_ID_BYTES = 256; + private static final int RECOVERY_BATCH_SIZE = 128; + + private final Path directory; + + SharedPointTransferCompensationStore(Path dataDirectory) { + this.directory = dataDirectory.toAbsolutePath().normalize().resolve(DIRECTORY); + } + + void record(String transferId) throws IOException { + byte[] contents = contents(transferId); + ensureSafeDirectory(); + Path target = marker(transferId); + if (Files.exists(target, LinkOption.NOFOLLOW_LINKS)) { + if (!Files.isRegularFile(target, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(target)) throw unsafe(); + return; + } + Path temporary = Files.createTempFile(directory, ".compensation-", ".tmp"); + try { + Files.write(temporary, contents, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE); + DurableFiles.forceFile(temporary); + try { + Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException unsupported) { + Files.move(temporary, target); + } + DurableFiles.forceDirectory(directory); + } finally { + Files.deleteIfExists(temporary); + } + } + + List loadBatch() throws IOException { + if (!Files.exists(directory, LinkOption.NOFOLLOW_LINKS)) return List.of(); + if (!Files.isDirectory(directory, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(directory)) throw unsafe(); + List transfers = new ArrayList<>(); + try (DirectoryStream entries = Files.newDirectoryStream(directory, "*" + SUFFIX)) { + for (Path entry : entries) { + if (transfers.size() == RECOVERY_BATCH_SIZE) break; + if (!Files.isRegularFile(entry, LinkOption.NOFOLLOW_LINKS) || Files.isSymbolicLink(entry) + || Files.size(entry) > MAX_TRANSFER_ID_BYTES) continue; + String transferId = Files.readString(entry, StandardCharsets.UTF_8); + if (marker(transferId).equals(entry.toAbsolutePath().normalize())) transfers.add(transferId); + } + } + return transfers; + } + + void remove(String transferId) throws IOException { + DurableFiles.deleteIfExists(marker(transferId)); + } + + 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 transferId) throws IOException { + return directory.resolve(hash(contents(transferId)) + SUFFIX).toAbsolutePath().normalize(); + } + + private static byte[] contents(String transferId) throws IOException { + if (transferId == null || transferId.isBlank()) throw unsafe(); + byte[] contents = transferId.getBytes(StandardCharsets.UTF_8); + if (contents.length > MAX_TRANSFER_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 point transfer compensation marker"); + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index d4e54f8be..f428bcf70 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -670,7 +670,9 @@ private String indexName(String suffix) { } private Connection connection() throws SQLException { - return table.getMysql().getConnectionManager().getConnection(); + Connection connection = table.getMysql().getConnectionManager().getConnection(); + if (connection == null) throw new SQLException("Unable to acquire shared MySQL connection"); + return connection; } private String qiJournal() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 3ffa9ea27..a01c0a46f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -40,7 +40,8 @@ import com.bencodez.votingplugin.events.SpecialRewardType; import com.bencodez.votingplugin.proxy.VoteTotalsSnapshot; import com.bencodez.votingplugin.topvoter.TopVoter; -import com.bencodez.votingplugin.topvoter.TopVoterPlayer; +import com.bencodez.votingplugin.topvoter.TopVoterPlayer; +import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.votesites.NextSite; import com.bencodez.votingplugin.votesites.VoteSite; @@ -333,11 +334,11 @@ public void setPointsStorageAware(int value, Consumer completion) { try { plugin.getTimer().execute(() -> { boolean updated = sharedPoints.setCommitted(this, value); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(updated), player); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(updated)); }); } catch (RuntimeException rejected) { plugin.debug(rejected); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(false), player); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false)); } } @@ -408,18 +409,20 @@ private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, int start, int end, boolean[] results, BiConsumer completion) { - try { - plugin.getBukkitScheduler().runTask(plugin, () -> { - for (int index = start; index < end; index++) { + for (int index = start; index < end; index++) { + VotingPluginUser user = users.get(index); + boolean success = results != null && results[index - start]; + try { + BukkitCompletionScheduler.run(plugin, user.getPlayer(), () -> { try { - completion.accept(users.get(index), results != null && results[index - start]); + completion.accept(user, success); } catch (RuntimeException failure) { plugin.debug(failure); } - } - }); - } catch (RuntimeException schedulingFailure) { - plugin.debug(schedulingFailure); + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + } } } @@ -442,12 +445,12 @@ public synchronized void addPointsStorageAware(int value, BiConsumer { SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); - plugin.getBukkitScheduler().runTask(plugin, - () -> completion.accept(result.success(), result.total()), player); + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(result.success(), result.total())); }); } catch (RuntimeException rejected) { plugin.debug(rejected); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(false, 0), player); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false, 0)); } } @@ -1579,11 +1582,11 @@ public void removePoints(int points, Consumer completion) { try { plugin.getTimer().execute(() -> { boolean removed = sharedPoints.remove(this, points); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(removed), player); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(removed)); }); } catch (RuntimeException rejected) { plugin.debug(rejected); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(false), player); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false)); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java new file mode 100644 index 000000000..ba8145880 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -0,0 +1,69 @@ +package com.bencodez.votingplugin.util; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.bukkit.entity.Player; + +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.votingplugin.VotingPluginMain; + +/** Schedules exactly-once completion work with an entity-retirement fallback. */ +public final class BukkitCompletionScheduler { + private BukkitCompletionScheduler() { + } + + public static void run(VotingPluginMain plugin, Player player, Runnable task) { + AtomicBoolean executed = new AtomicBoolean(); + Runnable once = () -> { + if (executed.compareAndSet(false, true)) task.run(); + }; + if (player == null) { + runGlobal(plugin, once); + return; + } + AtomicBoolean fallbackSubmitted = new AtomicBoolean(); + Runnable fallback = () -> { + if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, once); + }; + try { + if (plugin.getBukkitScheduler().getFoliaLib() == null) { + runLegacyEntity(plugin, player, once, fallback); + return; + } + CompletableFuture result = plugin.getBukkitScheduler().getFoliaLib().getImpl() + .runAtEntityWithFallback(player, ignored -> once.run(), fallback); + result.whenComplete((status, failure) -> { + // ENTITY_RETIRED invokes fallback itself. A scheduler that was already + // retired returns SCHEDULER_RETIRED without invoking it. + if (failure != null || status != EntityTaskResult.SUCCESS) { + fallback.run(); + } else if (!executed.get()) { + // Compatibility with scheduler adapters that report admission but do + // not run the consumer inline with future completion. + runLegacyEntity(plugin, player, once, fallback); + } + }); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + fallback.run(); + } + } + + private static void runLegacyEntity(VotingPluginMain plugin, Player player, Runnable task, Runnable fallback) { + try { + plugin.getBukkitScheduler().runTask(plugin, task, player); + } catch (RuntimeException legacyFailure) { + plugin.debug(legacyFailure); + fallback.run(); + } + } + + private static void runGlobal(VotingPluginMain plugin, Runnable task) { + try { + plugin.getBukkitScheduler().runTask(plugin, task); + } catch (RuntimeException schedulingFailure) { + plugin.debug(schedulingFailure); + } + } +} 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 1ac7dd924..b03baf9c6 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 @@ -10,6 +10,7 @@ public enum VoteShopPurchaseResult { SUCCESS, PENDING, + RECONCILIATION_REQUIRED, FAILED, SHOP_DISABLED, ITEM_NOT_FOUND, 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 8641d240d..ab0332b76 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 @@ -32,6 +32,7 @@ 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; @@ -179,7 +180,7 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, limitGeneration(item, System.currentTimeMillis())); } if (debit.result() != VoteShopPurchaseResult.SUCCESS) { - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(debit.result()), player); + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(debit.result())); return; } completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit); @@ -213,7 +214,7 @@ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, V AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); Runnable compensateBeforeClaim = () -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) return; - compensateSharedMysqlPurchase(player, user, completion, debit); + scheduleSharedMysqlCompensation(player, user, completion, debit); }; try { /* @@ -260,7 +261,7 @@ void scheduleClaimedReward(Player player, VotingPluginUser user, VoteShopItem it AtomicInteger state = new AtomicInteger(COMPLETION_PENDING); Runnable rejectBeforeStart = () -> { if (state.compareAndSet(COMPLETION_PENDING, COMPLETION_COMPENSATING)) { - compensateSharedMysqlPurchase(player, user, completion, debit); + scheduleSharedMysqlCompensation(player, user, completion, debit); } }; try { @@ -269,10 +270,33 @@ void scheduleClaimedReward(Player player, VotingPluginUser user, VoteShopItem it if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; try { completePurchase(player, user, item, placeholders, shopData); - plugin.getTimer().execute(() -> settleSharedMysqlPurchase(player, completion, debit)); } 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); } @@ -313,24 +337,29 @@ private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user, completeFailedPurchase(player, completion); return; } - Runnable compensation = () -> { - refundCompensatingMysqlDebit(user, debit); - plugin.getBukkitScheduler().runTask(plugin, - () -> completion.accept(VoteShopPurchaseResult.FAILED), player); - }; + // 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 schedulingFailure) { - plugin.debug(schedulingFailure); - // The row is already COMPENSATING even though the guarded reward callback - // was rejected. Do not leave that recoverable debit pending just because - // the persistence executor is concurrently shutting down. Bukkit's - // independent async scheduler also keeps JDBC off the entity lane. + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, compensation); - } catch (RuntimeException asyncSchedulingFailure) { - // Recovery owns the already-durable COMPENSATING row after shutdown. - plugin.debug(asyncSchedulingFailure); + } 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); } } @@ -338,8 +367,16 @@ private void compensateSharedMysqlPurchase(Player player, VotingPluginUser user, private void completeFailedPurchase(Player player, Consumer completion) { try { - plugin.getBukkitScheduler().runTask(plugin, - () -> completion.accept(VoteShopPurchaseResult.FAILED), player); + 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); } @@ -360,7 +397,16 @@ private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseD private void settleSharedMysqlPurchase(Player player, Consumer completion, SharedPurchaseDebit debit) { completeSharedMysqlPurchase(debit); - plugin.getBukkitScheduler().runTask(plugin, () -> completion.accept(VoteShopPurchaseResult.SUCCESS), player); + 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) { @@ -773,6 +819,11 @@ public void sendFailureMessage(Player player, VotingPluginUser user, VoteShopIte "&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 000000000..e87069597 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java @@ -0,0 +1,93 @@ +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.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; + +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.user.VotingPluginUser; + +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)); + } + + 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/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 6af54b547..dea5cbec0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -4,6 +4,8 @@ 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; @@ -19,6 +21,7 @@ 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; @@ -32,6 +35,7 @@ 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; @@ -71,12 +75,14 @@ void sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception { 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)); + }).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)); @@ -85,6 +91,8 @@ void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception { } 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 @@ -131,7 +139,8 @@ void sharedBulkPointMutationResubmitsBoundedChunks() throws Exception { 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(2)).runTask(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.scheduler, org.mockito.Mockito.times(65)).runTask(eq(fixture.plugin), any(Runnable.class), + eq(fixture.player)); } @Test @@ -273,7 +282,7 @@ void indeterminateSharedTransferClaimDoesNotReportSuccessBeforeApproval() throws ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); - verify(fixture.entityScheduler, never()).runAtEntityWithFallback(any(), any(), any(Runnable.class)); + verify(fixture.entityScheduler).runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class)); completion.getValue().run(); assertEquals(Boolean.FALSE, result.get()); } @@ -460,6 +469,57 @@ void sharedRemoveSkipsStaleCachedPointPrecheck() throws Exception { verify(fixture.statement).executeUpdate(); } + @Test + void nullSharedPointConnectionIsReportedAsASqlFailure() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn((Connection) null); + + assertFalse(fixture.user.removePoints(10)); + verify(fixture.plugin.getLogger()).severe(org.mockito.ArgumentMatchers.contains("SQLException")); + } + + @Test + void nullSharedAddConnectionCompletesCallbackWithoutASecondLookup() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn((Connection) null); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + AtomicReference success = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.addPointsStorageAware(5, (written, ignored) -> success.set(written)); + } + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + + assertEquals(Boolean.FALSE, success.get()); + verify(fixture.user, never()).getPoints(); + verify(fixture.sql.getConnectionManager()).getConnection(); + } + + @Test + void nullTransferJournalConnectionCompletesTheTransferAsFailure() throws Exception { + TransferSchedulingFixture fixture = transferSchedulingFixture(); + when(fixture.manager.getConnection()).thenReturn((Connection) null); + AtomicReference result = new AtomicReference<>(); + doAnswer(invocation -> { + invocation.getArgument(1).run(); + return null; + }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + + fixture.user.transferPoints(fixture.target, 10, result::set); + ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistence.capture()); + persistence.getValue().run(); + + assertEquals(Boolean.FALSE, result.get()); + verify(fixture.plugin.getLogger()).severe(org.mockito.ArgumentMatchers.contains("SQLException")); + } + @Test void sharedAbsoluteSetUsesTheDirectMysqlMutator() throws Exception { PointFixture fixture = pointFixture(); @@ -675,8 +735,8 @@ void retiredApprovalSchedulerRefundsClaimedTransferBeforeTheHookCanRun() throws verify(fixture.settlementPoint).setInt(1, 10); verify(fixture.settlementPoint).executeUpdate(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); - completion.getValue().run(); + verify(fixture.scheduler, org.mockito.Mockito.times(2)).runTask(eq(fixture.plugin), completion.capture()); + completion.getAllValues().get(1).run(); assertEquals(Boolean.FALSE, result.get()); } @@ -707,14 +767,16 @@ void rejectedClaimedTransferCompensationUsesAsyncFallback() throws Exception { verify(fixture.settlementPoint).setInt(1, 10); verify(fixture.settlementPoint).executeUpdate(); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); - completion.getValue().run(); + verify(fixture.scheduler, org.mockito.Mockito.times(2)).runTask(eq(fixture.plugin), completion.capture()); + completion.getAllValues().get(1).run(); assertEquals(Boolean.FALSE, result.get()); } @Test - void rejectedClaimedTransferRetainsDurableCompensationWhenBothFallbackSchedulersReject() throws Exception { + void rejectedClaimedTransferRetainsDurableCompensationWhenBothFallbackSchedulersReject( + @TempDir Path temporaryDirectory) throws Exception { SagaFixture fixture = sagaFixture(true); + when(fixture.plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); configureRejectedSagaConnections(fixture); when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); @@ -734,15 +796,38 @@ void rejectedClaimedTransferRetainsDurableCompensationWhenBothFallbackSchedulers .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); claimed.getAllValues().get(1).run(); - verify(fixture.compensationUpdate).setString(1, "COMPENSATING"); + verify(fixture.compensationUpdate, never()).setString(anyInt(), anyString()); verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); verify(fixture.settlementPoint, never()).executeUpdate(); + assertEquals(1, new SharedPointTransferCompensationStore(temporaryDirectory).loadBatch().size()); ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); - completion.getValue().run(); + verify(fixture.scheduler, org.mockito.Mockito.times(2)).runTask(eq(fixture.plugin), completion.capture()); + completion.getAllValues().get(1).run(); assertEquals(Boolean.FALSE, result.get()); } + @Test + void failedTransferCompensationMarkerIsRetriedByRecovery(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + SharedPointTransferJournal journal = mock(SharedPointTransferJournal.class); + when(journal.markCompensating("transfer-1")) + .thenThrow(new java.sql.SQLException("down")) + .thenReturn(true); + when(journal.recoverAndCleanup(anyLong())).thenReturn(java.util.List.of()); + SharedPointTransferCompensationStore store = + new SharedPointTransferCompensationStore(temporaryDirectory); + store.record("transfer-1"); + + SharedMysqlPointMutator.recoverTransfers(plugin, journal); + assertEquals(java.util.List.of("transfer-1"), store.loadBatch()); + SharedMysqlPointMutator.recoverTransfers(plugin, journal); + + assertTrue(store.loadBatch().isEmpty()); + verify(journal, org.mockito.Mockito.times(2)).markCompensating("transfer-1"); + verify(journal, org.mockito.Mockito.times(2)).recoverAndCleanup(anyLong()); + } + @Test void rejectedPersistenceClaimLeavesReservedTransferForOffThreadRecovery() throws Exception { SagaFixture fixture = sagaFixture(true); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java new file mode 100644 index 000000000..5865cb1be --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java @@ -0,0 +1,85 @@ +package com.bencodez.votingplugin.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import com.bencodez.simpleapi.folialib.FoliaLib; +import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; +import com.bencodez.simpleapi.folialib.impl.ServerImplementation; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.votingplugin.VotingPluginMain; + +class BukkitCompletionSchedulerTest { + @Test + void retiredEntityRunsCompletionOnceOnGlobalFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenAnswer(invocation -> { + invocation.getArgument(2, Runnable.class).run(); + return CompletableFuture.completedFuture(EntityTaskResult.ENTITY_RETIRED); + }); + AtomicInteger completions = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completions::incrementAndGet); + + assertEquals(1, completions.get()); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + @Test + void alreadyRetiredSchedulerRunsCompletionOnceOnGlobalFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + AtomicInteger completions = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completions::incrementAndGet); + + assertEquals(1, completions.get()); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + @Test + void missingSchedulerStatusRunsCompletionOnceOnGlobalFallback() { + Fixture fixture = fixture(); + when(fixture.entityScheduler.runAtEntityWithFallback(eq(fixture.player), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(null)); + AtomicInteger completions = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completions::incrementAndGet); + + assertEquals(1, completions.get()); + verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); + } + + private static Fixture fixture() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + FoliaLib folia = mock(FoliaLib.class); + ServerImplementation entityScheduler = mock(ServerImplementation.class); + Player player = mock(Player.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class)); + return new Fixture(plugin, scheduler, entityScheduler, player); + } + + private record Fixture(VotingPluginMain plugin, BukkitScheduler scheduler, + ServerImplementation entityScheduler, Player player) { + } +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 187aa401c..ce314a2cf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.inOrder; @@ -346,8 +347,11 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw // and refund are the only database connections in the scheduler-retirement path. An eighth // checkout would be the reward claim and would make the debit unrecoverable. verify(sql.getConnectionManager(), times(7)).getConnection(); - verify(entityScheduler).runAtEntityWithFallback( + verify(entityScheduler, times(2)).runAtEntityWithFallback( org.mockito.ArgumentMatchers.eq(player), any(), any(Runnable.class)); + ArgumentCaptor fallbackCompletion = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), fallbackCompletion.capture()); + fallbackCompletion.getValue().run(); scheduled.getValue().accept(null); assertEquals(1, completions.get(), "a compensated purchase must complete exactly once"); assertEquals(VoteShopPurchaseResult.FAILED, completionResult.get()); @@ -397,12 +401,11 @@ void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exce service.scheduleClaimedReward(mock(org.bukkit.entity.Player.class), user, mock(VoteShopItem.class), new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); - ArgumentCaptor refund = ArgumentCaptor.forClass(Runnable.class); - verify(persistenceExecutor).execute(refund.capture()); - InOrder markerBeforeFallback = inOrder(journal, persistenceExecutor); - markerBeforeFallback.verify(journal).markCompensating("purchase-1"); - markerBeforeFallback.verify(persistenceExecutor).execute(any(Runnable.class)); - refund.getValue().run(); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(compensation.capture()); + verify(journal, never()).markCompensating(anyString()); + compensation.getValue().run(); + verify(journal).markCompensating("purchase-1"); verify(journal).refundCompensatingReward("purchase-1"); callback.getValue().accept(null); verify(rewardHandler, never()).giveReward(any(), any(), any(), any()); @@ -443,7 +446,7 @@ void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { } @Test - void rejectedCompensationSchedulersLeaveADurableRecoveryMarker() throws Exception { + void rejectedClaimedRewardSettlementUsesAsyncFallbackAndCompletes() throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class); com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); @@ -451,10 +454,121 @@ void rejectedCompensationSchedulersLeaveADurableRecoveryMarker() throws Exceptio com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + RewardHandler rewardHandler = mock(RewardHandler.class); when(plugin.getBukkitScheduler()).thenReturn(scheduler); when(scheduler.getFoliaLib()).thenReturn(folia); when(folia.getImpl()).thenReturn(entityScheduler); when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("saturated")) + .when(persistenceExecutor).execute(any(Runnable.class)); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(player.getName()).thenReturn("player"); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + @SuppressWarnings("rawtypes") + ArgumentCaptor rewardCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + when(entityScheduler.runAtEntityWithFallback(eq(player), rewardCallback.capture(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getPlayerName()).thenReturn("player"); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getIdentifier()).thenReturn("item"); + when(item.getCost()).thenReturn(10); + when(item.getRewardsPath()).thenReturn("Shop.item.Rewards"); + when(item.getPurchaseMessage()).thenReturn("Purchased"); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + AtomicReference result = new AtomicReference<>(); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward(player, user, item, + new HashMap<>(), mock(FileConfiguration.class), result::set, debit); + rewardCallback.getValue().accept(null); + } + + ArgumentCaptor fallback = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTaskAsynchronously(eq(plugin), fallback.capture()); + fallback.getValue().run(); + verify(rewardHandler).giveReward(eq(user), any(FileConfiguration.class), eq("Shop.item.Rewards"), any()); + verify(journal).complete("purchase-1"); + assertEquals(VoteShopPurchaseResult.SUCCESS, result.get()); + } + + @Test + void claimedRewardFailureCompletesCallerAndRetainsJournalForReconciliation() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + org.mockito.Mockito.doThrow(new IllegalStateException("reward failed")) + .when(rewardHandler).giveReward(any(), any(), any(), any()); + + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getPlayerName()).thenReturn("player"); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getIdentifier()).thenReturn("item"); + when(item.getCost()).thenReturn(10); + when(item.getRewardsPath()).thenReturn("Shop.item.Rewards"); + @SuppressWarnings("rawtypes") + ArgumentCaptor rewardCallback = ArgumentCaptor.forClass(java.util.function.Consumer.class); + when(entityScheduler.runAtEntityWithFallback(eq(player), rewardCallback.capture(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SUCCESS)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + AtomicInteger completions = new AtomicInteger(); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward(player, user, item, + new HashMap<>(), mock(FileConfiguration.class), completion -> { + result.set(completion); + completions.incrementAndGet(); + }, debit); + + assertThrows(IllegalStateException.class, () -> rewardCallback.getValue().accept(null)); + assertEquals(1, completions.get()); + assertEquals(VoteShopPurchaseResult.RECONCILIATION_REQUIRED, result.get()); + verify(journal, never()).complete(anyString()); + verify(journal, never()).refundCompensatingReward(anyString()); + verify(persistenceExecutor, never()).execute(any(Runnable.class)); + } + + @Test + void rejectedCompensationSchedulersLeaveADurableRecoveryMarker(@TempDir Path temporaryDirectory) throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); when(entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) @@ -470,9 +584,11 @@ void rejectedCompensationSchedulersLeaveADurableRecoveryMarker() throws Exceptio mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); - verify(journal).markCompensating("purchase-1"); + verify(journal, never()).markCompensating(anyString()); verify(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); verify(journal, never()).refundCompensatingReward(anyString()); + assertEquals(java.util.List.of("purchase-1"), + new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); } @Test @@ -495,6 +611,9 @@ void failedCompensationMarkerIsRetriedByPeriodicRecovery(@TempDir Path temporary new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward( mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), new java.util.HashMap<>(), mock(FileConfiguration.class), ignored -> {}, debit); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(plugin.getTimer()).execute(compensation.capture()); + compensation.getValue().run(); assertEquals(java.util.List.of("purchase-1"), new SharedMysqlCompensationStore(temporaryDirectory).loadBatch()); VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin, journal); From c94842ee727a864b99a1c0f5b1d5ae116b576352 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:07:23 -0600 Subject: [PATCH 38/74] Harden vote shop recovery paths --- .../user/SharedMysqlPointMutator.java | 8 +++- .../service/SharedMysqlPurchaseJournal.java | 7 ++- .../service/VoteShopPurchaseService.java | 6 ++- .../VotingPluginUserPointSchedulingTest.java | 30 +++++++++++++ .../SharedMysqlPurchaseJournalTest.java | 8 ++-- .../service/VoteShopPurchaseServiceTest.java | 44 +++++++++++++++++++ 6 files changed, 96 insertions(+), 7 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 44c1c3445..e13a75e4f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -447,13 +447,19 @@ private void compensateRejectedTransfer(VotingPluginUser source, Consumer { if (failure != null || requiresCompensation(claim)) { if (state.compareAndSet(COMPLETION_RUNNING, COMPLETION_COMPENSATING)) { - compensateSharedMysqlPurchase(player, user, completion, debit); + // 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; } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index dea5cbec0..10ee61b80 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -806,6 +806,36 @@ void rejectedClaimedTransferRetainsDurableCompensationWhenBothFallbackSchedulers assertEquals(Boolean.FALSE, result.get()); } + @Test + void failedClaimedTransferCompensationKeepsRecoveryMarkerWhenMysqlFenceAndRefundFail( + @TempDir Path temporaryDirectory) throws Exception { + SagaFixture fixture = sagaFixture(true); + when(fixture.plugin.getDataFolder()).thenReturn(temporaryDirectory.toFile()); + configureRejectedSagaConnections(fixture); + when(fixture.entityScheduler.runAtEntityWithFallback(any(), any(), any(Runnable.class))) + .thenReturn(CompletableFuture.completedFuture(EntityTaskResult.SCHEDULER_RETIRED)); + + fixture.user.transferPoints(fixture.target, 10, ignored -> { }); + 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 claimed = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(claimed.capture()); + claimed.getAllValues().get(1).run(); + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(compensation.capture()); + + Connection unavailable = mock(Connection.class); + doThrow(new java.sql.SQLException("database unavailable")).when(unavailable).prepareStatement(anyString()); + when(fixture.manager.getConnection()).thenReturn(unavailable); + compensation.getAllValues().get(2).run(); + + assertEquals(1, new SharedPointTransferCompensationStore(temporaryDirectory).loadBatch().size()); + } + @Test void failedTransferCompensationMarkerIsRetriedByRecovery(@TempDir Path temporaryDirectory) throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 2ed6f1a90..355651a6e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -334,13 +334,13 @@ void schedulerProvenUnstartedHookCanBeRefunded() throws Exception { } @Test - void refundAcceptsHyphenatedConfiguredLimitIdentifier() throws Exception { + void refundAcceptsQuotedConfiguredLimitIdentifier() throws Exception { Fixture fixture = fixture(); PreparedStatement select = mock(PreparedStatement.class); PreparedStatement refund = mock(PreparedStatement.class); PreparedStatement terminal = mock(PreparedStatement.class); ResultSet pending = pendingRow(); - when(pending.getString(4)).thenReturn("VoteShopLimitdaily-key"); + when(pending.getString(4)).thenReturn("VoteShopLimitDaily Reward `special`"); when(pending.getString(6)).thenReturn(SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); when(fixture.work.prepareStatement(anyString())).thenReturn(select, refund, terminal); when(select.executeQuery()).thenReturn(pending); @@ -348,14 +348,14 @@ void refundAcceptsHyphenatedConfiguredLimitIdentifier() throws Exception { when(terminal.executeUpdate()).thenReturn(1); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); - boolean refunded = journal.refundPending("hyphenated-limit", 100L); + boolean refunded = journal.refundPending("quoted-limit", 100L); verify(refund).executeUpdate(); verify(terminal).executeUpdate(); assertTrue(refunded); org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); verify(fixture.work, org.mockito.Mockito.times(3)).prepareStatement(sql.capture()); - assertTrue(sql.getAllValues().get(1).contains("VoteShopLimitdaily-key")); + assertTrue(sql.getAllValues().get(1).contains("VoteShopLimitDaily Reward `special`")); } @Test diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index ce314a2cf..ac7980c59 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -445,6 +445,50 @@ void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { verify(journal).refundCompensatingReward("purchase-1"); } + @Test + void synchronousAsyncClaimRejectionSchedulesCompensationOffTheEntityLane() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + com.bencodez.simpleapi.folialib.FoliaLib folia = mock(com.bencodez.simpleapi.folialib.FoliaLib.class); + com.bencodez.simpleapi.folialib.impl.ServerImplementation entityScheduler = + mock(com.bencodez.simpleapi.folialib.impl.ServerImplementation.class); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(folia); + when(folia.getImpl()).thenReturn(entityScheduler); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + doAnswer(invocation -> { + @SuppressWarnings("rawtypes") + java.util.function.Consumer callback = invocation.getArgument(1, java.util.function.Consumer.class); + callback.accept(null); + return CompletableFuture.completedFuture(EntityTaskResult.SUCCESS); + }).when(entityScheduler).runAtEntityWithFallback(any(), any(), any(Runnable.class)); + org.mockito.Mockito.doThrow(new java.util.concurrent.RejectedExecutionException("stopping")) + .when(scheduler).runTaskAsynchronously(eq(plugin), any(Runnable.class)); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.markCompensating("purchase-1")).thenReturn(true); + when(journal.refundCompensatingReward("purchase-1")).thenReturn(false); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", null); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)); + + java.lang.reflect.Method complete = VoteShopPurchaseService.class.getDeclaredMethod("completeSharedMysqlPurchase", + org.bukkit.entity.Player.class, VotingPluginUser.class, VoteShopItem.class, HashMap.class, + FileConfiguration.class, java.util.function.Consumer.class, + VoteShopPurchaseService.SharedPurchaseDebit.class); + complete.setAccessible(true); + complete.invoke(service, mock(org.bukkit.entity.Player.class), mock(VotingPluginUser.class), mock(VoteShopItem.class), + new HashMap<>(), mock(FileConfiguration.class), (java.util.function.Consumer) ignored -> { }, + debit); + + ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(compensation.capture()); + verify(journal, never()).markCompensating(anyString()); + compensation.getValue().run(); + verify(journal).markCompensating("purchase-1"); + } + @Test void rejectedClaimedRewardSettlementUsesAsyncFallbackAndCompletes() throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class); From e8e931632b81e66b6c93e0c9bcc6e27b8587ae97 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:27:12 -0600 Subject: [PATCH 39/74] Exclude optimistic points from cache dumps --- .../user/SharedMysqlPointMutator.java | 60 +++++++++++++++++-- .../user/SharedMysqlPointMutatorTest.java | 49 +++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index e13a75e4f..fbabf7c79 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -4,7 +4,10 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.Collections; +import java.util.Map; import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -24,6 +27,17 @@ /** Performs point writes that must remain atomic across shared MySQL servers. */ final class SharedMysqlPointMutator { + /* + * An async addition may return a predicted total to its caller before its + * atomic database operation begins. UserDataCache is flushable, however, so + * that prediction must never be mistaken for an ordinary pending write when + * the persistence task drains unrelated cached fields. Keep the identity of + * those transient values separately rather than making the persisted cache + * format carry an optimistic-write flag. + */ + private static final Map> OPTIMISTIC_POINT_VALUES = + Collections.synchronizedMap(new WeakHashMap<>()); + private final VotingPluginMain plugin; SharedMysqlPointMutator(VotingPluginMain plugin) { @@ -73,7 +87,7 @@ int add(VotingPluginUser user, int amount, boolean async) { int predictedTotal = previousTotal + amount; cachePredictedPoints(user, predictedTotal); if (!run(() -> update(user, amount, false), true)) { - discardPointsCache(user); + discardOptimisticPoints(user); return previousTotal; } // The mutation has not happened yet, so the historical asynchronous API @@ -88,8 +102,40 @@ private void cachePredictedPoints(VotingPluginUser user, int predictedTotal) { if (cache == null) return; synchronized (cache) { var values = cache.getCache(); - if (values != null) values.put(user.getPointsPath(), new DataValueInt(predictedTotal)); + if (values == null) return; + DataValue prediction = new DataValueInt(predictedTotal); + values.put(user.getPointsPath(), prediction); + synchronized (OPTIMISTIC_POINT_VALUES) { + OPTIMISTIC_POINT_VALUES.computeIfAbsent(cache, ignored -> new java.util.HashMap<>()) + .put(user.getPointsPath(), prediction); + } + } + } + + /** + * Drops an optimistic value, if it is still the current cache value, before + * {@link UserDataCache#dump()} flushes other pending fields. The identity + * comparison deliberately preserves a later real point write that replaced + * the prediction while the async operation waited in the executor. + */ + private void discardOptimisticPoints(VotingPluginUser user) { + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + discardOptimisticPoints(cache, user.getPointsPath()); + } + } + + /** Caller holds {@code cache}'s monitor. */ + private void discardOptimisticPoints(UserDataCache cache, String path) { + DataValue prediction; + synchronized (OPTIMISTIC_POINT_VALUES) { + Map predictions = OPTIMISTIC_POINT_VALUES.get(cache); + prediction = predictions == null ? null : predictions.remove(path); + if (predictions != null && predictions.isEmpty()) OPTIMISTIC_POINT_VALUES.remove(cache); } + var values = cache.getCache(); + if (prediction != null && values != null && values.get(path) == prediction) values.remove(path); } AddResult addCommitted(VotingPluginUser user, int amount) { @@ -123,7 +169,7 @@ void addAndCap(VotingPluginUser user, int amount, int maximum, boolean async) { Math.min((long) previousTotal + amount, maximum)); cachePredictedPoints(user, predictedTotal); if (!run(() -> addAndCapAt(user, amount, maximum), true)) { - discardPointsCache(user); + discardOptimisticPoints(user); } } @@ -763,8 +809,12 @@ private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { } private void drainCache(VotingPluginUser user) { - if (user.isCached()) { - user.getCache().dump(); + if (!user.isCached()) return; + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + discardOptimisticPoints(cache, user.getPointsPath()); + cache.dump(); plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 3491ec24b..25fd43c50 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -218,6 +218,51 @@ void asynchronousAddUsesOnlyCachedPointsOnTheCallerThread() { 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 addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exception { MySQL table = mock(MySQL.class); @@ -382,6 +427,10 @@ void addAndCapUsesOneAtomicPersistenceMutation() throws Exception { 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()); From 3c049f4a511fec2d5c090b0c99895dd753ae1d0e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:41:04 -0600 Subject: [PATCH 40/74] Align weekly reset generation with configured period --- .../service/VoteShopPurchaseService.java | 5 ++-- .../service/VoteShopPurchaseServiceTest.java | 23 ++++++++++++------- 2 files changed, 17 insertions(+), 11 deletions(-) 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 1acf7e713..e5c716cff 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 @@ -758,9 +758,8 @@ private static LimitGeneration limitGeneration(LocalDateTime current, long nowMi } static String weeklyGenerationId(LocalDateTime current, int weekOffset) { - LocalDateTime weekTime = current.plusDays(weekOffset).toLocalDate() - .with(java.time.temporal.TemporalAdjusters.nextOrSame(java.time.DayOfWeek.MONDAY)).atStartOfDay(); - WeekFields fields = WeekFields.ISO; + LocalDateTime weekTime = current.plusDays(weekOffset); + WeekFields fields = WeekFields.of(Locale.getDefault()); return "W:" + weekTime.get(fields.weekBasedYear()) + '-' + weekTime.get(fields.weekOfWeekBasedYear()); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index ac7980c59..8d2ab4a46 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -3,6 +3,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -34,6 +35,7 @@ import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import java.util.HashMap; +import java.util.Locale; import java.util.UUID; import org.bukkit.configuration.file.FileConfiguration; @@ -43,6 +45,7 @@ import org.mockito.InOrder; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.time.TimeCalculation; import com.bencodez.advancedcore.api.user.UserData; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -90,14 +93,18 @@ void limitGenerationUsesTheEarliestConfiguredResetBoundary() { } @Test - void weeklyGenerationUsesANetworkWideCalendarConvention() { - LocalDateTime saturday = LocalDateTime.of(2026, 9, 5, 12, 0); - LocalDateTime sunday = saturday.plusDays(1); - LocalDateTime monday = sunday.plusDays(1); - assertEquals("W:2026-37", VoteShopPurchaseService.weeklyGenerationId(saturday, 0)); - assertEquals("W:2026-37", VoteShopPurchaseService.weeklyGenerationId(sunday, 0)); - assertEquals("W:2026-37", VoteShopPurchaseService.weeklyGenerationId(monday, 0)); - assertEquals("W:2026-38", VoteShopPurchaseService.weeklyGenerationId(saturday.plusWeeks(1), 0)); + void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { + LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); + int currentWeek = TimeCalculation.weekNumber(current, 0, Locale.getDefault()); + LocalDateTime nextBoundary = current.toLocalDate().plusDays(1).atStartOfDay(); + while (TimeCalculation.weekNumber(nextBoundary, 0, Locale.getDefault()) == currentWeek) { + nextBoundary = nextBoundary.plusDays(1); + } + + assertEquals(VoteShopPurchaseService.weeklyGenerationId(current, 0), + VoteShopPurchaseService.weeklyGenerationId(nextBoundary.minusNanos(1), 0)); + assertNotEquals(VoteShopPurchaseService.weeklyGenerationId(current, 0), + VoteShopPurchaseService.weeklyGenerationId(nextBoundary, 0)); } @Test From 367f941b296ee54d6610b0e5b9094c8c2c69ada5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:09:00 -0600 Subject: [PATCH 41/74] Complete atomic point transfer safeguards --- .../votingplugin/commands/CommandLoader.java | 18 ++++-- .../bencodez/votingplugin/config/Config.java | 11 +++- .../user/PointTransferResult.java | 10 +++ .../user/SharedMysqlPointMutator.java | 62 ++++++++++--------- .../votingplugin/user/VotingPluginUser.java | 18 +++++- .../service/VoteShopPurchaseService.java | 6 +- .../commands/CommandLoaderSchedulingTest.java | 20 ++++++ .../VotingPluginUserPointSchedulingTest.java | 60 ++++++++++++++---- .../service/VoteShopPurchaseServiceTest.java | 27 +++++++- 9 files changed, 182 insertions(+), 50 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 01c82f8a0..c11454f0d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -89,6 +89,7 @@ import com.bencodez.votingplugin.specialrewards.votestreak.VoteStreakType; import com.bencodez.votingplugin.topvoter.TopVoter; import com.bencodez.votingplugin.user.VotingPluginUser; +import com.bencodez.votingplugin.user.PointTransferResult; import com.bencodez.votingplugin.util.VoteTaskAdmission; import com.bencodez.votingplugin.util.BukkitCompletionScheduler; import com.bencodez.votingplugin.voteshop.service.VoteShopPurchaseResult; @@ -126,6 +127,16 @@ void runForVotingUser(VotingPluginUser user, Runnable task) { BukkitCompletionScheduler.run(plugin, user.getPlayer(), task); } + String transferFailureMessage(PointTransferResult result) { + if (result == PointTransferResult.INSUFFICIENT_POINTS) { + return plugin.getConfigFile().getFormatCommandsVoteGivePointsNotEnoughPoints(); + } + if (result == PointTransferResult.PENDING_CONFIRMATION) { + return plugin.getConfigFile().getFormatCommandsVoteGivePointsPendingConfirmation(); + } + return plugin.getConfigFile().getFormatCommandsVoteGivePointsUnavailable(); + } + /** * @return the adminPerm */ @@ -3786,8 +3797,8 @@ public void execute(CommandSender sender, String[] args) { } int pointsToGive = Integer.parseInt(args[2]); if (pointsToGive > 0) { - cPlayer.transferPoints(user, pointsToGive, transferred -> { - if (transferred) { + cPlayer.transferPointsWithResult(user, pointsToGive, result -> { + if (result == PointTransferResult.SUCCESS) { HashMap placeholders = new HashMap<>(); placeholders.put("transfer", "" + pointsToGive); placeholders.put("touser", "" + user.getPlayerName()); @@ -3802,8 +3813,7 @@ public void execute(CommandSender sender, String[] args) { .getConfigFile().getFormatCommandsVoteGivePointsTransferTo(), placeholders))); } else { - sendMessage(sender, plugin.getConfigFile() - .getFormatCommandsVoteGivePointsNotEnoughPoints()); + sendMessage(sender, transferFailureMessage(result)); } }); } else { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java index 4e52787b2..5e14c17c0 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/config/Config.java @@ -385,7 +385,16 @@ public String getDiscordSRVTopVoterRankDisplay(TopVoter topVoter) { @ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotEnoughPoints") @Getter - private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points"; + private String formatCommandsVoteGivePointsNotEnoughPoints = "&cNot enough points"; + + @ConfigDataString(path = "Format.Commands.Vote.GivePoints.Unavailable") + @Getter + private String formatCommandsVoteGivePointsUnavailable = "&cUnable to transfer points right now, please try again"; + + @ConfigDataString(path = "Format.Commands.Vote.GivePoints.PendingConfirmation") + @Getter + private String formatCommandsVoteGivePointsPendingConfirmation = + "&eTransfer pending confirmation; do not retry it"; @ConfigDataString(path = "Format.Commands.Vote.GivePoints.NotJoinedServer") @Getter diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java new file mode 100644 index 000000000..ba380ba14 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/PointTransferResult.java @@ -0,0 +1,10 @@ +package com.bencodez.votingplugin.user; + +/** The externally observable outcome of a point transfer. */ +public enum PointTransferResult { + SUCCESS, + INSUFFICIENT_POINTS, + CANCELLED, + PENDING_CONFIRMATION, + UNAVAILABLE +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index fbabf7c79..cce8987a3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -299,7 +299,7 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou * before the completion callback is posted back to the source entity lane. */ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, - IntFunction creditAmountProvider, Consumer completion) { + IntFunction creditAmountProvider, Consumer completion) { try { plugin.getTimer().execute(() -> { drainCache(source); @@ -315,7 +315,7 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target recoverTransfers(plugin, journal); if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, System.currentTimeMillis())) { - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.INSUFFICIENT_POINTS); return; } // The source cache may have been recreated while the reservation was being @@ -324,7 +324,7 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target discardPointsCache(source, sourcePoints); } catch (SQLException failure) { logFailure(failure); - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); return; } @@ -355,12 +355,12 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target // No reservation exists when the initial persistence task is rejected. // Still complete the command contract on the source entity lane. plugin.debug(schedulingFailure); - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); } } private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, - IntFunction creditAmountProvider, Consumer completion, + IntFunction creditAmountProvider, Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints) { SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, System.currentTimeMillis()); @@ -371,7 +371,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser } catch (SQLException failure) { logFailure(failure); } - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); return; } if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { @@ -414,7 +414,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser } catch (RuntimeException asyncSchedulingFailure) { plugin.debug(asyncSchedulingFailure); logIndeterminateClaim(transferId); - completeOnBukkit(source, completion, true); + completeOnBukkit(source, completion, PointTransferResult.PENDING_CONFIRMATION); } } finally { approvalState.set(2); @@ -429,7 +429,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser } } - private void scheduleRejectedTransferCompensation(VotingPluginUser source, Consumer completion, + private void scheduleRejectedTransferCompensation(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { Runnable compensation = () -> compensateRejectedTransfer(source, completion, journal, transferId, sourcePoints, debitAmount); @@ -442,7 +442,7 @@ private void scheduleRejectedTransferCompensation(VotingPluginUser source, Consu } catch (RuntimeException asyncRejected) { plugin.debug(asyncRejected); rememberPendingCompensationMarker(plugin, transferId); - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); } } } @@ -483,13 +483,13 @@ private static SharedPointTransferCompensationStore compensationStore(VotingPlug return new SharedPointTransferCompensationStore(plugin.getDataFolder().toPath()); } - private void compensateRejectedTransfer(VotingPluginUser source, Consumer completion, + private void compensateRejectedTransfer(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { try { // The CAS fence proves the approval callback cannot run. Write the // recoverable state before relying on completion delivery. if (!journal.markCompensating(transferId)) { - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); return; } } catch (SQLException markerFailure) { @@ -507,7 +507,7 @@ private void compensateRejectedTransfer(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, + Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints, Integer approvedAmount) { - boolean transferred; + PointTransferResult result; try { // The hook may have recreated either cache while it ran on Bukkit. drainCache(target); @@ -529,17 +529,19 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, in discardPointsCache(source, sourcePoints); discardPointsCache(target, targetPoints); } - transferred = isAcceptedSettlement(outcome); + result = outcome == SharedPointTransferJournal.SettlementOutcome.COMPLETED ? PointTransferResult.SUCCESS + : outcome == SharedPointTransferJournal.SettlementOutcome.REFUNDED ? PointTransferResult.CANCELLED + : PointTransferResult.PENDING_CONFIRMATION; } catch (RuntimeException failure) { plugin.getLogger().severe("Unable to settle shared MySQL point transfer: " + failure.getClass().getSimpleName()); plugin.debug(failure); - transferred = false; + result = PointTransferResult.UNAVAILABLE; } - completeOnBukkit(source, completion, transferred); + completeOnBukkit(source, completion, result); } - private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { @@ -550,10 +552,10 @@ private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consu logFailure(refundFailure); } plugin.debug(failure); - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); } - private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, Consumer completion, + private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { boolean refunded = false; try { @@ -578,17 +580,20 @@ private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, Con logFailure(markerFailure); } if (refunded) discardPointsCache(source, sourcePoints); - if (!refunded) logIndeterminateClaim(transferId); - completeOnBukkit(source, completion, false); + if (!refunded) { + rememberPendingCompensationMarker(plugin, transferId); + logIndeterminateClaim(transferId); + } + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); } - void completeRejectedPersistenceSubmission(VotingPluginUser source, Consumer completion, + void completeRejectedPersistenceSubmission(VotingPluginUser source, Consumer completion, RuntimeException failure) { plugin.debug(failure); - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); } - private void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + private void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { @@ -599,7 +604,7 @@ private void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consum logFailure(refundFailure); } plugin.debug(failure); - completeOnBukkit(source, completion, false); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); } private boolean isAcceptedSettlement(SharedPointTransferJournal.SettlementOutcome outcome) { @@ -842,8 +847,9 @@ private void discardPointsCache(VotingPluginUser user, String pointsColumn) { SharedMysqlCacheReconciler.invalidate(plugin, user.getUUID(), pointsColumn); } - private void completeOnBukkit(VotingPluginUser source, Consumer completion, boolean transferred) { - BukkitCompletionScheduler.run(plugin, source.getPlayer(), () -> completion.accept(transferred)); + private void completeOnBukkit(VotingPluginUser source, Consumer completion, + PointTransferResult result) { + BukkitCompletionScheduler.run(plugin, source.getPlayer(), () -> completion.accept(result)); } private void logFailure(SQLException failure) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index a01c0a46f..28c5bcf1e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1599,6 +1599,22 @@ public void removePoints(int points, Consumer completion) { * @param completion whether the transfer completed */ public void transferPoints(VotingPluginUser target, int points, Consumer completion) { + transferPointsWithResult(target, points, result -> completion.accept(completesLegacyTransfer(result))); + } + + /** + * Legacy callers must not retry an indeterminate transfer: its approval hook + * has run and its durable journal row is retained for reconciliation. + */ + static boolean completesLegacyTransfer(PointTransferResult result) { + return result == PointTransferResult.SUCCESS || result == PointTransferResult.PENDING_CONFIRMATION; + } + + /** + * Transfers points and reports whether a failed transfer was caused by a + * conditional debit or by cancellation/availability. + */ + public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.applies()) { sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { @@ -1612,7 +1628,7 @@ public void transferPoints(VotingPluginUser target, int points, Consumer result = new AtomicReference<>(); + AtomicReference result = new AtomicReference<>(); doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); - fixture.user.transferPoints(fixture.target, 10, result::set); + 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(Boolean.FALSE, result.get()); + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); } @Test - void indeterminateSharedTransferClaimDoesNotReportSuccessBeforeApproval() throws Exception { + 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, @@ -285,6 +286,7 @@ void indeterminateSharedTransferClaimDoesNotReportSuccessBeforeApproval() throws 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 @@ -505,18 +507,18 @@ void nullSharedAddConnectionCompletesCallbackWithoutASecondLookup() throws Excep void nullTransferJournalConnectionCompletesTheTransferAsFailure() throws Exception { TransferSchedulingFixture fixture = transferSchedulingFixture(); when(fixture.manager.getConnection()).thenReturn((Connection) null); - AtomicReference result = new AtomicReference<>(); + AtomicReference result = new AtomicReference<>(); doAnswer(invocation -> { invocation.getArgument(1).run(); return null; }).when(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); - fixture.user.transferPoints(fixture.target, 10, result::set); + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(persistence.capture()); persistence.getValue().run(); - assertEquals(Boolean.FALSE, result.get()); + assertEquals(PointTransferResult.UNAVAILABLE, result.get()); verify(fixture.plugin.getLogger()).severe(org.mockito.ArgumentMatchers.contains("SQLException")); } @@ -680,11 +682,11 @@ void rejectedApprovalSettlementSubmissionUsesBukkitAsyncFallbackWithApprovedAmou } @Test - void rejectedApprovalSettlementSchedulersRetainHookStartedForReconciliation() throws Exception { + void rejectedApprovalSettlementSchedulersReportPendingConfirmation() throws Exception { SagaFixture fixture = sagaFixture(true); - AtomicReference result = new AtomicReference<>(); + AtomicReference result = new AtomicReference<>(); - fixture.user.transferPoints(fixture.target, 10, result::set); + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); ArgumentCaptor reservation = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence).execute(reservation.capture()); reservation.getValue().run(); @@ -707,7 +709,13 @@ void rejectedApprovalSettlementSchedulersRetainHookStartedForReconciliation() th ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); completion.getValue().run(); - assertEquals(Boolean.TRUE, result.get()); + assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); + } + + @Test + void legacyTransferCallbackTreatsPendingConfirmationAsSuccessfulToSuppressRetry() { + assertTrue(VotingPluginUser.completesLegacyTransfer(PointTransferResult.PENDING_CONFIRMATION)); + assertFalse(VotingPluginUser.completesLegacyTransfer(PointTransferResult.UNAVAILABLE)); } @Test @@ -971,6 +979,36 @@ void sharedTransferCreditsTheEventAdjustedRecipientAmountAtomically() throws Exc verify(fixture.settlementPoint).setInt(1, 4); } + @Test + void indeterminateSettlementConfirmationReportsPendingConfirmation() throws Exception { + SagaFixture fixture = sagaFixture(true); + Connection unavailable = mock(Connection.class); + when(unavailable.prepareStatement(anyString())).thenThrow(new java.sql.SQLException("confirmation unavailable")); + when(fixture.manager.getConnection()).thenReturn(fixture.schema, fixture.recoveryReserved, fixture.cleanup, + fixture.lookup, fixture.reservation, fixture.claim, fixture.settlement, + unavailable, unavailable, unavailable); + doThrow(new java.sql.SQLException("settlement acknowledgement lost")).when(fixture.settlement).commit(); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); + } + @Test void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws Exception { SagaFixture fixture = sagaFixture(false); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 8d2ab4a46..2a035062c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -95,9 +95,9 @@ void limitGenerationUsesTheEarliestConfiguredResetBoundary() { @Test void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); - int currentWeek = TimeCalculation.weekNumber(current, 0, Locale.getDefault()); + int currentWeek = TimeCalculation.weekNumber(current, 0, Locale.ROOT); LocalDateTime nextBoundary = current.toLocalDate().plusDays(1).atStartOfDay(); - while (TimeCalculation.weekNumber(nextBoundary, 0, Locale.getDefault()) == currentWeek) { + while (TimeCalculation.weekNumber(nextBoundary, 0, Locale.ROOT) == currentWeek) { nextBoundary = nextBoundary.plusDays(1); } @@ -107,6 +107,29 @@ void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { VoteShopPurchaseService.weeklyGenerationId(nextBoundary, 0)); } + @Test + void weeklyGenerationDoesNotDependOnTheJvmDefaultLocale() { + Locale previous = Locale.getDefault(); + LocalDateTime current = LocalDateTime.of(2027, 1, 3, 12, 0); + long now = current.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + try { + Locale.setDefault(Locale.US); + String usGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); + VoteShopPurchaseService.LimitGeneration usLimit = VoteShopPurchaseService.limitGeneration( + current, now, false, true, false, 0); + Locale.setDefault(Locale.GERMANY); + String germanGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); + VoteShopPurchaseService.LimitGeneration germanLimit = VoteShopPurchaseService.limitGeneration( + current, now, false, true, false, 0); + + assertEquals(usGeneration, germanGeneration); + assertEquals(usLimit.value(), germanLimit.value()); + assertEquals(usLimit.expiresAt(), germanLimit.expiresAt()); + } finally { + Locale.setDefault(previous); + } + } + @Test void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { MySQL table = mock(MySQL.class); From 96c850408be9bcc1fce0b899bc75a518cdfffcff Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:02:08 -0600 Subject: [PATCH 42/74] Harden shared MySQL purchase completion --- .../rewards/builtin/RewardPoints.java | 8 +- .../user/SharedMysqlCacheReconciler.java | 27 +++++++ .../user/SharedMysqlPointMutator.java | 39 +++------ .../service/VoteShopPurchaseService.java | 54 +++++++------ .../rewards/builtin/RewardPointsTest.java | 8 +- .../VotingPluginUserPointSchedulingTest.java | 52 ++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 79 +++++++++++++++++++ 7 files changed, 210 insertions(+), 57 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index 07f54fefc..eaa261bd9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -46,10 +46,10 @@ public void onValidate(Reward reward, RewardInject inject, ConfigurationSection public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - // Reward injection is a synchronous chain: later rewards can consume the - // newpoints placeholder immediately. Wait for the atomic shared-MySQL update - // and committed balance rather than publishing an optimistic queued value. - String result = "" + vpUser.addPoints(num); + // Reward injection is a synchronous chain, so publish the storage-aware + // predicted total immediately while shared-MySQL persistence stays off the + // Bukkit/Folia entity lane. Ordinary storage retains its synchronous path. + String result = "" + vpUser.addPointsStorageAware(num); plugin.debug("Setting points to " + result); return result; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index e217a133b..9ea7b066c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -2,16 +2,43 @@ import java.util.Map; import java.util.UUID; +import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.votingplugin.VotingPluginMain; /** Invalidates only fields changed directly by a shared-MySQL mutation. */ public final class SharedMysqlCacheReconciler { + private static final Map> OPTIMISTIC_POINT_VALUES = + java.util.Collections.synchronizedMap(new WeakHashMap<>()); + private SharedMysqlCacheReconciler() { } + static void recordOptimisticPoint(UserDataCache cache, String path, DataValue prediction) { + synchronized (OPTIMISTIC_POINT_VALUES) { + OPTIMISTIC_POINT_VALUES.computeIfAbsent(cache, ignored -> new java.util.HashMap<>()) + .put(path, prediction); + } + } + + /** Removes only the still-current predicted point value before any cache dump. */ + public static void discardOptimisticPoint(UserDataCache cache, String path) { + if (cache == null || path == null) return; + synchronized (cache) { + DataValue prediction; + synchronized (OPTIMISTIC_POINT_VALUES) { + Map predictions = OPTIMISTIC_POINT_VALUES.get(cache); + prediction = predictions == null ? null : predictions.remove(path); + if (predictions != null && predictions.isEmpty()) OPTIMISTIC_POINT_VALUES.remove(cache); + } + var values = cache.getCache(); + if (prediction != null && values != null && values.get(path) == prediction) values.remove(path); + } + } + /** * Invalidates an existing cache without creating one or doing JDBC work. The * mutation has already committed before this method runs. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index cce8987a3..1edc853c6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -4,10 +4,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; -import java.util.Collections; -import java.util.Map; import java.util.UUID; -import java.util.WeakHashMap; import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; @@ -35,9 +32,6 @@ final class SharedMysqlPointMutator { * those transient values separately rather than making the persisted cache * format carry an optimistic-write flag. */ - private static final Map> OPTIMISTIC_POINT_VALUES = - Collections.synchronizedMap(new WeakHashMap<>()); - private final VotingPluginMain plugin; SharedMysqlPointMutator(VotingPluginMain plugin) { @@ -105,10 +99,7 @@ private void cachePredictedPoints(VotingPluginUser user, int predictedTotal) { if (values == null) return; DataValue prediction = new DataValueInt(predictedTotal); values.put(user.getPointsPath(), prediction); - synchronized (OPTIMISTIC_POINT_VALUES) { - OPTIMISTIC_POINT_VALUES.computeIfAbsent(cache, ignored -> new java.util.HashMap<>()) - .put(user.getPointsPath(), prediction); - } + SharedMysqlCacheReconciler.recordOptimisticPoint(cache, user.getPointsPath(), prediction); } } @@ -122,20 +113,8 @@ private void discardOptimisticPoints(VotingPluginUser user) { UserDataCache cache = user.getCache(); if (cache == null) return; synchronized (cache) { - discardOptimisticPoints(cache, user.getPointsPath()); - } - } - - /** Caller holds {@code cache}'s monitor. */ - private void discardOptimisticPoints(UserDataCache cache, String path) { - DataValue prediction; - synchronized (OPTIMISTIC_POINT_VALUES) { - Map predictions = OPTIMISTIC_POINT_VALUES.get(cache); - prediction = predictions == null ? null : predictions.remove(path); - if (predictions != null && predictions.isEmpty()) OPTIMISTIC_POINT_VALUES.remove(cache); + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); } - var values = cache.getCache(); - if (prediction != null && values != null && values.get(path) == prediction) values.remove(path); } AddResult addCommitted(VotingPluginUser user, int amount) { @@ -302,8 +281,14 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target IntFunction creditAmountProvider, Consumer completion) { try { plugin.getTimer().execute(() -> { - drainCache(source); - drainCache(target); + try { + drainCache(source); + drainCache(target); + } catch (RuntimeException cacheFailure) { + plugin.debug(cacheFailure); + completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + return; + } MySQL table = plugin.getMysql(); String sourcePoints = source.getPointsPath(); String targetPoints = target.getPointsPath(); @@ -536,7 +521,7 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, in plugin.getLogger().severe("Unable to settle shared MySQL point transfer: " + failure.getClass().getSimpleName()); plugin.debug(failure); - result = PointTransferResult.UNAVAILABLE; + result = PointTransferResult.PENDING_CONFIRMATION; } completeOnBukkit(source, completion, result); } @@ -818,7 +803,7 @@ private void drainCache(VotingPluginUser user) { UserDataCache cache = user.getCache(); if (cache == null) return; synchronized (cache) { - discardOptimisticPoints(cache, user.getPointsPath()); + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); cache.dump(); plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); } 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 d0aa6d4c7..bc2798591 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 @@ -24,6 +24,7 @@ import com.bencodez.advancedcore.api.time.TimeCalculation; 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; @@ -172,18 +173,23 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, HashMap placeholders = purchasePlaceholders(item); try { plugin.getTimer().execute(() -> { - 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; + 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); } - completeSharedMysqlPurchase(player, user, item, placeholders, shopData, completion, debit); }); } catch (RuntimeException persistenceRejected) { plugin.debug(persistenceRejected); @@ -527,10 +533,7 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item MySQL table = plugin.getMysql(); String pointsColumn = user.getPointsPath(); String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; - if (user.isCached()) { - user.getCache().dump(); - plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), 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(" - ?"); @@ -576,13 +579,7 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo MySQL table = plugin.getMysql(); String pointsColumn = user.getPointsPath(); String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; - if (user.isCached()) { - // dump() waits for a cache batch that has already left its queue. Removing - // the drained cache also prevents an older absolute write from racing the - // conditional debit on the shared database. - user.getCache().dump(); - plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); - } + drainPurchaseCache(user, pointsColumn); if (limitColumn != null) { table.checkColumn(limitColumn, DataType.INTEGER); } @@ -607,6 +604,19 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null); } + private void drainPurchaseCache(VotingPluginUser user, String pointsColumn) { + 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()); 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 index 0d50c8455..871aba720 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java @@ -18,20 +18,20 @@ class RewardPointsTest { @Test - void waitsForCommittedPointTotalBeforePublishingNewpoints() { + 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.addPoints(5)).thenReturn(73); + when(user.addPointsStorageAware(5)).thenReturn(73); String result = new RewardPoints(plugin).onRewardRequest(mock(Reward.class), advancedUser, 5, new HashMap<>()); assertEquals("73", result); - verify(user).addPoints(5); - verify(user, never()).addPointsStorageAware(5); + verify(user).addPointsStorageAware(5); + verify(user, never()).addPoints(5); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 72b83c33a..9908f80c3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -1009,6 +1009,58 @@ void indeterminateSettlementConfirmationReportsPendingConfirmation() throws Exce assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); } + @Test + void postApprovalCacheFailureReportsPendingConfirmation() throws Exception { + SagaFixture fixture = sagaFixture(true); + UserDataCache targetCache = mock(UserDataCache.class); + when(fixture.target.isCached()).thenReturn(false, true); + when(fixture.target.getCache()).thenReturn(targetCache); + when(targetCache.getCache()).thenReturn(new HashMap<>()); + doThrow(new IllegalStateException("cache dump failed")).when(targetCache).dump(); + AtomicReference result = new AtomicReference<>(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + ArgumentCaptor persistenceWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(persistenceWork.capture()); + persistenceWork.getValue().run(); + runTransferApprovalGate(fixture.persistence, fixture.scheduler, fixture.plugin, + fixture.entityScheduler, fixture.targetPlayer); + ArgumentCaptor settlementWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlementWork.capture()); + settlementWork.getAllValues().get(2).run(); + ArgumentCaptor completion = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), completion.capture(), eq(fixture.player)); + completion.getValue().run(); + } + + assertEquals(PointTransferResult.PENDING_CONFIRMATION, result.get()); + verify(fixture.settlementPoint, never()).executeUpdate(); + } + + @Test + void initialTransferCacheFailureCompletesAsUnavailable() throws Exception { + SagaFixture fixture = sagaFixture(true); + UserDataCache sourceCache = mock(UserDataCache.class); + doReturn(true).when(fixture.user).isCached(); + doReturn(sourceCache).when(fixture.user).getCache(); + when(sourceCache.getCache()).thenReturn(new HashMap<>()); + doThrow(new IllegalStateException("cache dump failed")).when(sourceCache).dump(); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPointsWithResult(fixture.target, 10, result::set); + 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(PointTransferResult.UNAVAILABLE, result.get()); + verify(fixture.debit, never()).executeUpdate(); + } + @Test void sharedTransferDoesNotFireRecipientEventWhenConditionalDebitFails() throws Exception { SagaFixture fixture = sagaFixture(false); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 2a035062c..e4ee692da 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -54,6 +54,7 @@ import com.bencodez.advancedcore.api.rewards.RewardHandler; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; import com.bencodez.votingplugin.VotingPluginMain; +import com.bencodez.votingplugin.user.SharedMysqlCacheReconciler; import com.bencodez.votingplugin.voteshop.shop.VoteShopDefinition; import com.bencodez.votingplugin.user.VotingPluginUser; import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; @@ -272,6 +273,41 @@ void rejectedInitialSharedMysqlSubmissionCompletesAsFailed() { verify(table, never()).getMysql(); } + @Test + void sharedMysqlCacheDrainFailureCompletesAsFailed() { + MySQL table = mock(MySQL.class); + VotingPluginMain plugin = sharedMysqlPlugin(table); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + VotingPluginUser user = purchaseUser(); + UserDataCache cache = mock(UserDataCache.class); + when(user.isCached()).thenReturn(true); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(new HashMap<>()); + org.mockito.Mockito.doThrow(new IllegalStateException("cache dump failed")).when(cache).dump(); + AtomicReference result = new AtomicReference<>(); + + new VoteShopPurchaseService(plugin, definition).purchase(player, user, item, result::set); + ArgumentCaptor databaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(persistenceExecutor).execute(databaseWork.capture()); + databaseWork.getValue().run(); + + assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + verify(table, never()).getMysql(); + } + @Test void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throws Exception { MySQL table = mock(MySQL.class); @@ -741,6 +777,49 @@ void sharedMysqlDebitWaitsForAndRemovesExistingCache() throws Exception { java.util.UUID.fromString("00000000-0000-0000-0000-000000000001"), null); } + @Test + void sharedMysqlDebitStripsOptimisticPointsBeforeDumpingOtherCachedFields() 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 = sharedMysqlPlugin(table); + VotingPluginUser user = purchaseUser(); + UserDataCache cache = mock(UserDataCache.class); + DataValue prediction = mock(DataValue.class); + DataValue dailyTotal = mock(DataValue.class); + HashMap values = new HashMap<>(); + values.put("Points", prediction); + values.put("DailyTotal", dailyTotal); + when(user.isCached()).thenReturn(true, false); + when(user.getCache()).thenReturn(cache); + when(cache.getCache()).thenReturn(values); + java.lang.reflect.Method record = SharedMysqlCacheReconciler.class.getDeclaredMethod( + "recordOptimisticPoint", UserDataCache.class, String.class, DataValue.class); + record.setAccessible(true); + record.invoke(null, cache, "Points", prediction); + doAnswer(invocation -> { + assertFalse(values.containsKey("Points")); + assertSame(dailyTotal, values.get("DailyTotal")); + return null; + }).when(cache).dump(); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getCost()).thenReturn(10); + when(item.getLimit()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(plugin, null).debitSharedMysql(user, item)); + + verify(cache).dump(); + } + @Test void sharedMysqlDebitClosesItsConnectionBeforeRefreshingTheCache() throws Exception { MySQL table = mock(MySQL.class); From 7ed6948462fabc60748bab1ff3850636172337be Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:43:24 -0600 Subject: [PATCH 43/74] Await durable shared point rewards --- .../rewards/builtin/RewardPoints.java | 18 ++++++++ .../votingplugin/user/VotingPluginUser.java | 42 +++++++++++++++++++ .../service/VoteShopPurchaseService.java | 6 +++ .../rewards/builtin/RewardPointsTest.java | 41 ++++++++++++++++++ .../VotingPluginUserPointSchedulingTest.java | 27 ++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 30 +++++++++++++ 6 files changed, 164 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index eaa261bd9..6207c6143 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.rewards.builtin; import java.util.HashMap; +import java.util.concurrent.CompletionStage; import org.bukkit.Material; import org.bukkit.configuration.ConfigurationSection; @@ -53,4 +54,21 @@ public String onRewardRequest(Reward reward, com.bencodez.advancedcore.api.user. plugin.debug("Setting points to " + result); return result; } + + @Override + public boolean supportsAsyncRequest() { + return true; + } + + @Override + public CompletionStage onRewardRequestAsync(Reward reward, + com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, + HashMap placeholders) { + VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); + return vpUser.addPointsStorageAwareAsync(num).thenApply(total -> { + String result = String.valueOf(total); + plugin.debug("Setting points to " + result); + return result; + }); + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 28c5bcf1e..167105476 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -15,6 +15,8 @@ import java.util.Map; import java.util.Map.Entry; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -256,6 +258,46 @@ public int addPointsStorageAware(int value) { return addPoints(value, new SharedMysqlPointMutator(plugin).applies()); } + /** + * Adds points and completes with the committed total. Shared-MySQL work runs on + * the persistence executor; ordinary storage preserves its synchronous write. + * The returned stage never reports a predicted shared-MySQL value. + * + * @param value point delta + * @return committed point total, or an exceptional stage when persistence fails + */ + public synchronized CompletionStage addPointsStorageAwareAsync(int value) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + return CompletableFuture.completedFuture(getPoints()); + } + SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); + if (!sharedPoints.applies()) { + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, false); + return CompletableFuture.completedFuture(newTotal); + } + + CompletableFuture completion = new CompletableFuture<>(); + try { + plugin.getTimer().execute(() -> { + try { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); + if (result.success()) completion.complete(result.total()); + else completion.completeExceptionally( + new IllegalStateException("Unable to persist shared MySQL points")); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + return completion; + } + /** * Adds points and reports the committed total after shared-MySQL persistence * completes. The callback runs on the user's Bukkit/entity lane. 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 bc2798591..02468df78 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 @@ -398,6 +398,12 @@ private void refundCompensatingMysqlDebit(VotingPluginUser user, SharedPurchaseD 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); 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 index 871aba720..caf9fd4ce 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java @@ -7,6 +7,8 @@ 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; @@ -34,4 +36,43 @@ void publishesStorageAwarePointTotalWithoutBlockingTheRewardLane() { 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); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 9908f80c3..c0f719319 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -351,6 +351,33 @@ void storageAwareAddReportsOnlyAfterCommittedSharedWrite() throws Exception { 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 sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index e4ee692da..6003d7eb0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -477,6 +477,36 @@ void rejectedClaimedRewardQueuesDurableRefundAndFencesLateCallback() throws Exce verify(rewardHandler, never()).giveReward(any(), any(), any(), any()); } + @Test + void indeterminateRefundInvalidatesPointAndLimitSnapshots() throws Exception { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + VotingPluginUser user = mock(VotingPluginUser.class); + String uuid = "00000000-0000-0000-0000-000000000001"; + when(user.getUUID()).thenReturn(uuid); + UserDataCache cache = mock(UserDataCache.class); + java.util.HashMap values = new java.util.HashMap<>(); + values.put("Points", mock(DataValue.class)); + values.put("VoteShopLimit-item", mock(DataValue.class)); + when(cache.getCache()).thenReturn(values); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(UUID.fromString(uuid), cache))); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + when(journal.refundCompensatingReward("purchase-1")) + .thenThrow(new java.sql.SQLException("commit acknowledgement lost")); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", "VoteShopLimit-item"); + + java.lang.reflect.Method refund = VoteShopPurchaseService.class.getDeclaredMethod( + "refundCompensatingMysqlDebit", VotingPluginUser.class, + VoteShopPurchaseService.SharedPurchaseDebit.class); + refund.setAccessible(true); + refund.invoke(new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)), user, debit); + + assertFalse(values.containsKey("Points")); + assertFalse(values.containsKey("VoteShopLimit-item")); + verify(journal).refundCompensatingReward("purchase-1"); + } + @Test void rejectedCompensationExecutorStillRunsTheDurableRefund() throws Exception { VotingPluginMain plugin = mock(VotingPluginMain.class); From adcfa279d81e61fb45ddafaeaa096785ddba7de4 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Thu, 10 Sep 2026 20:15:27 -0600 Subject: [PATCH 44/74] Make shared point rewards retry safe --- .../rewards/builtin/RewardPoints.java | 42 ++- .../user/SharedMysqlPointMutator.java | 31 ++- .../user/SharedPointAdditionJournal.java | 251 ++++++++++++++++++ .../votingplugin/user/VotingPluginUser.java | 15 +- .../user/SharedMysqlPointMutatorTest.java | 35 +++ .../user/SharedPointAdditionJournalTest.java | 179 +++++++++++++ 6 files changed, 547 insertions(+), 6 deletions(-) create mode 100644 VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index 6207c6143..eb6b19de6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -1,5 +1,9 @@ package com.bencodez.votingplugin.rewards.builtin; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.HashMap; import java.util.concurrent.CompletionStage; @@ -65,10 +69,46 @@ public CompletionStage onRewardRequestAsync(Reward reward, com.bencodez.advancedcore.api.user.AdvancedCoreUser user, int num, HashMap placeholders) { VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); - return vpUser.addPointsStorageAwareAsync(num).thenApply(total -> { + String operationId = replayOperationId(vpUser); + CompletionStage addition = operationId == null ? vpUser.addPointsStorageAwareAsync(num) + : vpUser.addPointsStorageAwareAsync(num, operationId); + return addition.thenApply(total -> { String result = String.valueOf(total); plugin.debug("Setting points to " + result); return result; }); } + + /** + * AdvancedCore #317 exposes a durable occurrence identity for a queued replay + * plus its active stage path. Use both when present, but retain compatibility + * with releases that cannot distinguish a retry from a new reward occurrence. + */ + private static String replayOperationId(VotingPluginUser user) { + try { + Method currentReplayOccurrenceId = Reward.class.getMethod("currentReplayOccurrenceId"); + Method currentReplayKey = Reward.class.getMethod("currentReplayKey"); + Object occurrence = currentReplayOccurrenceId.invoke(null); + Object value = currentReplayKey.invoke(null); + if (!(occurrence instanceof String) || ((String) occurrence).isEmpty() + || !(value instanceof String) || ((String) value).isEmpty()) return null; + return sha256("VotingPlugin:shared-points-reward:v1\0" + user.getUUID() + '\0' + occurrence + '\0' + value); + } catch (ReflectiveOperationException | SecurityException ignored) { + return null; + } + } + + private static String sha256(String value) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder result = new StringBuilder(digest.length * 2); + for (byte element : digest) { + result.append(Character.forDigit((element >>> 4) & 0xf, 16)); + result.append(Character.forDigit(element & 0xf, 16)); + } + return result.toString(); + } catch (NoSuchAlgorithmException failure) { + throw new IllegalStateException("SHA-256 is unavailable", failure); + } + } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 1edc853c6..318690bba 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -121,6 +121,26 @@ AddResult addCommitted(VotingPluginUser user, int amount) { return addAndReadCommittedResult(user, amount); } + /** + * Adds points under a caller-supplied durable operation id. Retryable reward + * stages use this overload so an ambiguous commit can be confirmed on their + * next invocation without applying the credit again. + */ + AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { + if (operationId == null || operationId.isEmpty()) return addCommitted(user, amount); + drainCache(user); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .add(operationId, user.getUUID(), user.getPointsPath(), amount, System.currentTimeMillis()); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(false, 0); + } finally { + discardPointsCache(user); + } + } + void set(VotingPluginUser user, int value, boolean async) { run(() -> setAbsolute(user, value), async); } @@ -578,15 +598,18 @@ void completeRejectedPersistenceSubmission(VotingPluginUser source, Consumer completion, + void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { - if (journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount)) { - discardPointsCache(source, sourcePoints); - } + journal.refundHookStarted(transferId, source.getUUID(), sourcePoints, debitAmount); } catch (SQLException refundFailure) { logFailure(refundFailure); + } finally { + // The refund may have committed even when its acknowledgement and + // confirmation read both failed. Invalidate a cache recreated after the + // original debit so it cannot later overwrite either durable outcome. + discardPointsCache(source, sourcePoints); } plugin.debug(failure); completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java new file mode 100644 index 000000000..385511e35 --- /dev/null +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -0,0 +1,251 @@ +package com.bencodez.votingplugin.user; + +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.HashSet; +import java.util.Iterator; +import java.util.Set; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.mysql.DbType; + +/** + * Durable, idempotent shared-MySQL point additions for retryable reward stages. + * + *

The journal update and the point credit commit in the same transaction. A + * retry with the same operation id can consequently prove the earlier credit + * instead of applying the delta a second time after a lost commit acknowledgement.

+ */ +final class SharedPointAdditionJournal { + private static final String COMPLETED = "COMPLETED"; + private static final int MAX_IDENTIFIER_BYTES = 63; + private static final String JOURNAL_SUFFIX = "_PointAdditions"; + private static final String HASHED_TABLE_PREFIX = "vp_pa_"; + 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; + + SharedPointAdditionJournal(MySQL table, boolean initializeSchema) throws SQLException { + this.table = table; + journalTable = journalTableName(table.getTableName()); + if (initializeSchema) ensureSchema(); + } + + static String journalTableName(String sourceTable) { + String legacyName = sourceTable + JOURNAL_SUFFIX; + if (legacyName.getBytes(StandardCharsets.UTF_8).length <= MAX_IDENTIFIER_BYTES) return legacyName; + return HASHED_TABLE_PREFIX + hash(sourceTable + '\0' + JOURNAL_SUFFIX).substring(0, HASHED_TABLE_HEX_LENGTH); + } + + static SharedPointAdditionJournal forTable(MySQL table) throws SQLException { + synchronized (INITIALIZED) { + expungeInitialized(); + for (IdentityWeakReference marker : INITIALIZED) { + if (marker.get() == table) return new SharedPointAdditionJournal(table, false); + } + new SharedPointAdditionJournal(table, true); + INITIALIZED.add(new IdentityWeakReference(table, INITIALIZED_QUEUE)); + return new SharedPointAdditionJournal(table, false); + } + } + + /** Applies the operation exactly once and returns the resulting durable total. */ + AdditionResult add(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + AdditionRow existing = find(operationId); + if (existing != null) return existingResult(operationId, existing, uuid, pointsColumn, amount); + + String insert = "INSERT INTO " + qiJournal() + " (" + qi("operation_id") + ", " + qi("player_uuid") + + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("state") + ", " + + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?)"; + String points = qi(pointsColumn); + String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String read = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast(); + String complete = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("total_points") + + " = ? WHERE " + qi("operation_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement insertStatement = connection.prepareStatement(insert); + PreparedStatement updateStatement = connection.prepareStatement(update); + PreparedStatement readStatement = connection.prepareStatement(read); + PreparedStatement completeStatement = connection.prepareStatement(complete)) { + insertStatement.setString(1, operationId); + insertStatement.setString(2, uuid); + insertStatement.setString(3, pointsColumn); + insertStatement.setInt(4, amount); + insertStatement.setString(5, COMPLETED); + insertStatement.setNull(6, java.sql.Types.INTEGER); + insertStatement.setLong(7, now); + insertStatement.executeUpdate(); + + updateStatement.setInt(1, amount); + updateStatement.setString(2, uuid); + if (updateStatement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point user row missing"); + } + readStatement.setString(1, uuid); + int total; + try (ResultSet result = readStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point user row disappeared"); + } + total = result.getInt(1); + } + completeStatement.setString(1, COMPLETED); + completeStatement.setInt(2, total); + completeStatement.setString(3, operationId); + if (completeStatement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point addition journal row missing"); + } + commitAndConfirm(connection, operationId); + return new AdditionResult(total); + } catch (SQLException failure) { + rollback(connection); + if (isDuplicate(failure)) { + closeQuietly(connection); + AdditionRow duplicate = find(operationId); + if (duplicate != null) return existingResult(operationId, duplicate, uuid, pointsColumn, amount); + } + throw failure; + } + } + } + + private AdditionResult existingResult(String operationId, AdditionRow row, String uuid, String pointsColumn, + int amount) throws SQLException { + if (!row.matches(uuid, pointsColumn, amount)) throw new SQLException("Mismatched shared point addition operation"); + if (!COMPLETED.equals(row.state) || row.total == null) { + throw new SQLException("Shared point addition operation is not confirmable: " + operationId); + } + return new AdditionResult(row.total.intValue()); + } + + private void commitAndConfirm(Connection connection, String operationId) throws SQLException { + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AdditionRow row = find(operationId); + if (row != null && COMPLETED.equals(row.state) && row.total != null) return; + throw ambiguousCommit; + } + } + + private AdditionRow find(String operationId) throws SQLException { + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { + statement.setString(1, operationId); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) return null; + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + return new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), total); + } + } + } + + private void ensureSchema() throws SQLException { + String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("operation_id") + + " VARCHAR(64) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NOT NULL, " + + qi("points_column") + " VARCHAR(128) NOT NULL, " + qi("amount") + " INT NOT NULL, " + + qi("state") + " VARCHAR(16) NOT NULL, " + qi("total_points") + " INT NULL, " + + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("operation_id") + "));"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + } + } + + 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(); + } + } + + 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); + } + } + + private static boolean isSafeColumn(String column) { + return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + } + + 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 qi(String identifier) { return table.qi(identifier); } + private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } + + private static void rollback(Connection connection) { + try { + connection.rollback(); + } catch (SQLException ignored) { + // The follow-up lookup determines whether an ambiguous commit landed. + } + } + + private static void closeQuietly(Connection connection) { + try { + connection.close(); + } catch (SQLException ignored) { + // Confirmation remains safe even if this broken handle cannot close cleanly. + } + } + + private static boolean isDuplicate(SQLException failure) { + return "23505".equals(failure.getSQLState()) || failure.getErrorCode() == 1062; + } + + 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(); + } + } + + record AdditionResult(int total) {} + private record AdditionRow(String uuid, String pointsColumn, int amount, String state, Integer total) { + boolean matches(String expectedUuid, String expectedPointsColumn, int expectedAmount) { + return uuid.equals(expectedUuid) && pointsColumn.equals(expectedPointsColumn) && amount == expectedAmount; + } + } +} diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 167105476..80ea8b210 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -267,6 +267,19 @@ public int addPointsStorageAware(int value) { * @return committed point total, or an exceptional stage when persistence fails */ public synchronized CompletionStage addPointsStorageAwareAsync(int value) { + return addPointsStorageAwareAsync(value, null); + } + + /** + * Adds points and completes with the committed total, optionally binding the + * addition to a durable operation id supplied by a retryable reward stage. + * Callers without a stable id retain the historical behavior. + * + * @param value point delta + * @param operationId stable id for a retry of the same logical addition, or null + * @return committed point total, or an exceptional stage when persistence fails + */ + public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) { @@ -283,7 +296,7 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu try { plugin.getTimer().execute(() -> { try { - SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints(), operationId); if (result.success()) completion.complete(result.total()); else completion.completeExceptionally( new IllegalStateException("Unable to persist shared MySQL points")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 25fd43c50..bf0c33007 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -4,6 +4,7 @@ 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.doThrow; import static org.mockito.Mockito.inOrder; @@ -21,6 +22,7 @@ import java.util.concurrent.ScheduledExecutorService; 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; @@ -34,6 +36,39 @@ import com.bencodez.votingplugin.VotingPluginMain; class SharedMysqlPointMutatorTest { + @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, 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); 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 000000000..52003cf08 --- /dev/null +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -0,0 +1,179 @@ +package com.bencodez.votingplugin.user; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.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 org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; + +class SharedPointAdditionJournalTest { + @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 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")); + } + + 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 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; + } +} From 68976d5f25108f01a3bebb4e15b5ca20785b7d7e Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Fri, 11 Sep 2026 05:42:37 -0600 Subject: [PATCH 45/74] Harden indeterminate shared point operations --- .../rewards/builtin/RewardPoints.java | 17 +++++- .../user/SharedMysqlPointMutator.java | 42 ++++++++++++- .../user/SharedPointAdditionJournal.java | 61 ++++++++++++++++++- .../votingplugin/user/VotingPluginUser.java | 5 ++ .../service/VoteShopPurchaseService.java | 9 +++ .../rewards/builtin/RewardPointsTest.java | 19 ++++++ .../user/SharedMysqlPointMutatorTest.java | 47 ++++++++++++++ .../user/SharedPointAdditionJournalTest.java | 50 +++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 14 +++++ 9 files changed, 260 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java index eb6b19de6..528958c5f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/rewards/builtin/RewardPoints.java @@ -79,6 +79,15 @@ public CompletionStage onRewardRequestAsync(Reward reward, }); } + @Override + public CompletionStage onReplayCheckpointPersisted(Reward reward, + com.bencodez.advancedcore.api.user.AdvancedCoreUser user, String occurrenceId, String injectionKey) { + VotingPluginUser vpUser = plugin.getVotingPluginUserManager().getVotingPluginUser(user); + String operationId = replayOperationId(vpUser, occurrenceId, injectionKey); + return operationId == null ? java.util.concurrent.CompletableFuture.completedFuture(null) + : vpUser.acknowledgeStorageAwarePointOperation(operationId); + } + /** * AdvancedCore #317 exposes a durable occurrence identity for a queued replay * plus its active stage path. Use both when present, but retain compatibility @@ -92,12 +101,18 @@ private static String replayOperationId(VotingPluginUser user) { Object value = currentReplayKey.invoke(null); if (!(occurrence instanceof String) || ((String) occurrence).isEmpty() || !(value instanceof String) || ((String) value).isEmpty()) return null; - return sha256("VotingPlugin:shared-points-reward:v1\0" + user.getUUID() + '\0' + occurrence + '\0' + value); + return replayOperationId(user, (String) occurrence, (String) value); } catch (ReflectiveOperationException | SecurityException ignored) { return null; } } + private static String replayOperationId(VotingPluginUser user, String occurrenceId, String injectionKey) { + if (occurrenceId == null || occurrenceId.isEmpty() || injectionKey == null || injectionKey.isEmpty()) return null; + return sha256("VotingPlugin:shared-points-reward:v1\0" + user.getUUID() + '\0' + occurrenceId + '\0' + + injectionKey); + } + private static String sha256(String value) { try { byte[] digest = MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 318690bba..01bc58c75 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -6,6 +6,7 @@ import java.sql.SQLException; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Consumer; @@ -52,8 +53,20 @@ static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { * to the plugin lifecycle, so no independent task survives shutdown. */ static void scheduleTransferRecovery(VotingPluginMain plugin) { - plugin.getTimer().execute(() -> recoverTransfers(plugin)); - plugin.getTimer().scheduleWithFixedDelay(() -> recoverTransfers(plugin), 1L, 1L, TimeUnit.MINUTES); + plugin.getTimer().execute(() -> recoverSharedPointJournals(plugin)); + plugin.getTimer().scheduleWithFixedDelay(() -> recoverSharedPointJournals(plugin), 1L, 1L, TimeUnit.MINUTES); + } + + private static void recoverSharedPointJournals(VotingPluginMain plugin) { + recoverTransfers(plugin); + if (!usesSharedMysqlPoints(plugin)) return; + try { + SharedPointAdditionJournal.forTable(plugin.getMysql()).cleanupAcknowledged(System.currentTimeMillis()); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to clean up shared MySQL point additions: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } } private static void recoverTransfers(VotingPluginMain plugin) { @@ -141,6 +154,27 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { } } + CompletionStage acknowledgePointAddition(String operationId) { + if (!applies() || operationId == null || operationId.isEmpty()) { + return CompletableFuture.completedFuture(null); + } + CompletableFuture completion = new CompletableFuture<>(); + try { + plugin.getTimer().execute(() -> { + try { + SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, + System.currentTimeMillis()); + completion.complete(null); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + completion.completeExceptionally(rejected); + } + return completion; + } + void set(VotingPluginUser user, int value, boolean async) { run(() -> setAbsolute(user, value), async); } @@ -328,6 +362,10 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target // dump can restore the pre-debit balance. discardPointsCache(source, sourcePoints); } catch (SQLException failure) { + // An acknowledgement/confirmation failure can follow a committed + // reservation debit. Never allow a cache recreated during the unknown + // outcome to flush the pre-debit source balance over it. + discardPointsCache(source, sourcePoints); logFailure(failure); completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); return; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 385511e35..8587b29af 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -12,6 +12,7 @@ import java.util.HashSet; import java.util.Iterator; 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; @@ -25,6 +26,10 @@ */ final class SharedPointAdditionJournal { private static final String COMPLETED = "COMPLETED"; + private static final String ACKNOWLEDGED = "ACKNOWLEDGED"; + /* Matches the bounded durable reconciliation window used by shared transfers. */ + static final long COMPLETED_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7); + private static final int CLEANUP_BATCH_SIZE = 100; private static final int MAX_IDENTIFIER_BYTES = 63; private static final String JOURNAL_SUFFIX = "_PointAdditions"; private static final String HASHED_TABLE_PREFIX = "vp_pa_"; @@ -129,12 +134,50 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo private AdditionResult existingResult(String operationId, AdditionRow row, String uuid, String pointsColumn, int amount) throws SQLException { if (!row.matches(uuid, pointsColumn, amount)) throw new SQLException("Mismatched shared point addition operation"); - if (!COMPLETED.equals(row.state) || row.total == null) { + if (!(COMPLETED.equals(row.state) || ACKNOWLEDGED.equals(row.state)) || row.total == null) { throw new SQLException("Shared point addition operation is not confirmable: " + operationId); } return new AdditionResult(row.total.intValue()); } + /** Marks an applied operation safe to retire after its replay checkpoint is durable. */ + void acknowledge(String operationId, long now) throws SQLException { + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("created_at") + + " = ? WHERE " + qi("operation_id") + " = ? AND " + qi("state") + " = ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) { + statement.setString(1, ACKNOWLEDGED); + statement.setLong(2, now); + statement.setString(3, operationId); + statement.setString(4, COMPLETED); + statement.executeUpdate(); + } + } + + /** Removes a bounded batch of replay-acknowledged entries after the retention window. */ + void cleanupAcknowledged(long now) throws SQLException { + long cutoff = now - COMPLETED_RETENTION_MILLIS; + String select = "SELECT " + qi("operation_id") + " FROM " + qiJournal() + " WHERE " + qi("state") + + " = ? AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? AND " + + qi("state") + " = ? AND " + qi("created_at") + " <= ?"; + try (Connection connection = connection(); PreparedStatement selectStatement = connection.prepareStatement(select); + PreparedStatement deleteStatement = connection.prepareStatement(delete)) { + selectStatement.setString(1, ACKNOWLEDGED); + selectStatement.setLong(2, cutoff); + selectStatement.setInt(3, CLEANUP_BATCH_SIZE); + Set operationIds = new java.util.LinkedHashSet<>(); + try (ResultSet result = selectStatement.executeQuery()) { + while (result.next()) operationIds.add(result.getString(1)); + } + for (String operationId : operationIds) { + deleteStatement.setString(1, operationId); + deleteStatement.setString(2, ACKNOWLEDGED); + deleteStatement.setLong(3, cutoff); + deleteStatement.executeUpdate(); + } + } + } + private void commitAndConfirm(Connection connection, String operationId) throws SQLException { try { connection.commit(); @@ -168,6 +211,18 @@ private void ensureSchema() throws SQLException { + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("operation_id") + "));"; try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { statement.executeUpdate(); + createIndex(connection); + } + } + + private void createIndex(Connection connection) throws SQLException { + String indexName = "vp_pa_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_state_created"; + String create = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") + + qi(indexName) + " ON " + qiJournal() + " (" + qi("state") + ", " + qi("created_at") + ");"; + try (PreparedStatement statement = connection.prepareStatement(create)) { + statement.executeUpdate(); + } catch (SQLException failure) { + if (!isDuplicateIndex(failure)) throw failure; } } @@ -227,6 +282,10 @@ private static boolean isDuplicate(SQLException failure) { return "23505".equals(failure.getSQLState()) || failure.getErrorCode() == 1062; } + private static boolean isDuplicateIndex(SQLException failure) { + return failure.getErrorCode() == 1061 || "42P07".equals(failure.getSQLState()); + } + private static final class IdentityWeakReference extends WeakReference { private final int identityHash; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 80ea8b210..d039490f9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -311,6 +311,11 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu return completion; } + /** Retires an idempotent point-addition record after its replay checkpoint is durable. */ + public CompletionStage acknowledgeStorageAwarePointOperation(String operationId) { + return new SharedMysqlPointMutator(plugin).acknowledgePointAddition(operationId); + } + /** * Adds points and reports the committed total after shared-MySQL persistence * completes. The callback runs on the user's Bukkit/entity lane. 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 02468df78..3a408a7f2 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 @@ -561,6 +561,10 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item 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); @@ -602,6 +606,11 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo 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); 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 index caf9fd4ce..d4abd1133 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/rewards/builtin/RewardPointsTest.java @@ -75,4 +75,23 @@ void asyncRewardPropagatesPersistenceFailure() { 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/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index bf0c33007..c5ff94bc3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -36,6 +36,53 @@ import com.bencodez.votingplugin.VotingPluginMain; class SharedMysqlPointMutatorTest { + @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); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 52003cf08..59ba929f2 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -14,6 +14,7 @@ import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; @@ -103,6 +104,55 @@ void journalTableNameRemainsPortableForLongSourceNames() { SharedPointAdditionJournal.journalTableName("VotingPlugin_Users")); } + @Test + void onlyReplayAcknowledgedEntriesAreExpiredInABoundedRetentionBatch() 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).setLong(2, now - SharedPointAdditionJournal.COMPLETED_RETENTION_MILLIS); + verify(select).setInt(3, 100); + 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 createIndex = mock(PreparedStatement.class); + when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(createTable, createIndex); + + new SharedPointAdditionJournal(fixture.table, true); + + org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.initialLookup, times(2)).prepareStatement(statements.capture()); + assertTrue(statements.getAllValues().get(1).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); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 6003d7eb0..67150a44d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -956,6 +956,15 @@ void sharedMysqlReservationDatabaseFailureReportsFailed() throws Exception { mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); when(plugin.getTimer()).thenReturn(persistenceExecutor); when(plugin.getBukkitScheduler()).thenReturn(scheduler); + UUID uuid = UUID.fromString("00000000-0000-0000-0000-000000000001"); + UserDataCache recreatedCache = mock(UserDataCache.class); + HashMap recreatedValues = new HashMap<>(); + recreatedValues.put("Points", mock(DataValue.class)); + recreatedValues.put("VoteShopLimititem", mock(DataValue.class)); + recreatedValues.put("DailyTotal", mock(DataValue.class)); + when(recreatedCache.getCache()).thenReturn(recreatedValues); + when(plugin.getUserManager().getDataManager().getUserDataCache()).thenReturn( + new java.util.concurrent.ConcurrentHashMap<>(java.util.Map.of(uuid, recreatedCache))); org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); doAnswer(invocation -> { invocation.getArgument(1, Runnable.class).run(); @@ -965,6 +974,8 @@ void sharedMysqlReservationDatabaseFailureReportsFailed() throws Exception { when(definition.isEnabled()).thenReturn(true); VoteShopItem item = mock(VoteShopItem.class); when(item.getPermission()).thenReturn(""); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("item"); AtomicReference result = new AtomicReference<>(); new VoteShopPurchaseService(plugin, definition).purchase(player, purchaseUser(), item, result::set); @@ -973,6 +984,9 @@ void sharedMysqlReservationDatabaseFailureReportsFailed() throws Exception { databaseWork.getValue().run(); assertEquals(VoteShopPurchaseResult.FAILED, result.get()); + assertFalse(recreatedValues.containsKey("Points")); + assertFalse(recreatedValues.containsKey("VoteShopLimititem")); + assertTrue(recreatedValues.containsKey("DailyTotal")); verify(plugin.getRewardHandler(), never()).giveReward(any(), any(), any(), any()); } From 0647d07b2e96a3e059622c3b14bd11ba8554f97f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:01:38 -0600 Subject: [PATCH 46/74] Fence shared vote shop resets from cache dumps --- VotingPlugin/pom.xml | 6 ++ .../service/VoteShopPurchaseService.java | 71 +++++++++++++------ .../service/VoteShopPurchaseServiceTest.java | 39 +++++++++- 3 files changed, 93 insertions(+), 23 deletions(-) diff --git a/VotingPlugin/pom.xml b/VotingPlugin/pom.xml index 58910bcdb..24bdd4960 100644 --- a/VotingPlugin/pom.xml +++ b/VotingPlugin/pom.xml @@ -339,6 +339,12 @@ 3.8.2-SNAPSHOT compile + + com.google.code.gson + gson + 2.14.0 + provided + org.junit.jupiter junit-jupiter-engine 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 3a408a7f2..77ac2dce0 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 @@ -13,6 +13,7 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; import org.bukkit.Bukkit; @@ -48,6 +49,8 @@ public class VoteShopPurchaseService { private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); + private static final ReentrantReadWriteLock SHARED_MYSQL_CACHE_RESET_FENCE = + new ReentrantReadWriteLock(true); private static final int COMPLETION_PENDING = 0; private static final int COMPLETION_RUNNING = 1; private static final int COMPLETION_COMPENSATING = 2; @@ -493,20 +496,42 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo /** 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; - // 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); + 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); + } + }); + } + + static void withSharedMysqlCacheResetFence(Runnable action) { + var lock = SHARED_MYSQL_CACHE_RESET_FENCE.writeLock(); + lock.lock(); 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); + action.run(); } finally { - SharedMysqlCacheReconciler.invalidateAllAndRefresh(plugin, limitColumn); + lock.unlock(); + } + } + + static void withSharedMysqlCacheDumpFence(Runnable action) { + var lock = SHARED_MYSQL_CACHE_RESET_FENCE.readLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); } } @@ -620,16 +645,18 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo } private void drainPurchaseCache(VotingPluginUser user, String pointsColumn) { - 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); - } + 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) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 67150a44d..e50914c8e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -199,10 +199,47 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { verify(refreshedUser).cache(); } + @Test + void sharedMysqlResetWaitsForAnInFlightCacheDump() throws Exception { + CountDownLatch dumpEntered = new CountDownLatch(1); + CountDownLatch releaseDump = new CountDownLatch(1); + CountDownLatch resetAttempted = new CountDownLatch(1); + CountDownLatch resetEntered = new CountDownLatch(1); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future dump = workers.submit(() -> VoteShopPurchaseService.withSharedMysqlCacheDumpFence(() -> { + dumpEntered.countDown(); + try { + assertTrue(releaseDump.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError(interrupted); + } + })); + assertTrue(dumpEntered.await(1, TimeUnit.SECONDS)); + + Future reset = workers.submit(() -> { + resetAttempted.countDown(); + VoteShopPurchaseService.withSharedMysqlCacheResetFence(resetEntered::countDown); + }); + assertTrue(resetAttempted.await(1, TimeUnit.SECONDS)); + assertFalse(resetEntered.await(100, TimeUnit.MILLISECONDS), + "a reset must not commit while an older cache dump can still write"); + + releaseDump.countDown(); + dump.get(1, TimeUnit.SECONDS); + reset.get(1, TimeUnit.SECONDS); + assertEquals(0, resetEntered.getCount()); + } finally { + releaseDump.countDown(); + workers.shutdownNow(); + } + } + @Test void localPurchaseRefreshesCacheBeforeCheckingPointsWhenConfigured() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); - when(plugin.getStorageType()).thenReturn(UserStorage.FLAT); + when(plugin.getStorageType()).thenReturn(UserStorage.SQLITE); when(plugin.getConfigFile().isExtraVoteShopCheck()).thenReturn(true); VoteShopDefinition definition = mock(VoteShopDefinition.class); when(definition.isEnabled()).thenReturn(true); From 8bc2e1c960688a2591e997c0749e30469352ab7a Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:15:53 -0600 Subject: [PATCH 47/74] Fence all shared MySQL cache dumps --- .../user/SharedMysqlCacheReconciler.java | 24 +++++++++ .../user/SharedMysqlPointMutator.java | 18 ++++--- .../service/VoteShopPurchaseService.java | 19 +------ .../user/SharedMysqlPointMutatorTest.java | 50 +++++++++++++++++++ 4 files changed, 86 insertions(+), 25 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index 9ea7b066c..471b8ff63 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -4,6 +4,7 @@ import java.util.UUID; import java.util.WeakHashMap; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantReadWriteLock; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.simpleapi.sql.data.DataValue; @@ -11,12 +12,35 @@ /** Invalidates only fields changed directly by a shared-MySQL mutation. */ public final class SharedMysqlCacheReconciler { + private static final ReentrantReadWriteLock RESET_FENCE = new ReentrantReadWriteLock(true); private static final Map> OPTIMISTIC_POINT_VALUES = java.util.Collections.synchronizedMap(new WeakHashMap<>()); private SharedMysqlCacheReconciler() { } + /** Excludes every shared-MySQL cache dump while a limit reset is committing. */ + public static void withResetFence(Runnable action) { + var lock = RESET_FENCE.writeLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } + + /** Allows concurrent cache drains while excluding a shared-MySQL limit reset. */ + public static void withCacheDumpFence(Runnable action) { + var lock = RESET_FENCE.readLock(); + lock.lock(); + try { + action.run(); + } finally { + lock.unlock(); + } + } + static void recordOptimisticPoint(UserDataCache cache, String path, DataValue prediction) { synchronized (OPTIMISTIC_POINT_VALUES) { OPTIMISTIC_POINT_VALUES.computeIfAbsent(cache, ignored -> new java.util.HashMap<>()) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 01bc58c75..0412f8b0a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -860,14 +860,16 @@ private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { } private void drainCache(VotingPluginUser user) { - if (!user.isCached()) return; - UserDataCache cache = user.getCache(); - if (cache == null) return; - synchronized (cache) { - SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); - cache.dump(); - plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); - } + SharedMysqlCacheReconciler.withCacheDumpFence(() -> { + if (!user.isCached()) return; + UserDataCache cache = user.getCache(); + if (cache == null) return; + synchronized (cache) { + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); + cache.dump(); + plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); + } + }); } private static Connection requireConnection(MySQL table) throws SQLException { 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 77ac2dce0..f8602ad0f 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 @@ -13,7 +13,6 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.locks.ReentrantReadWriteLock; import java.util.function.Consumer; import org.bukkit.Bukkit; @@ -49,8 +48,6 @@ public class VoteShopPurchaseService { private static final int PURCHASE_LOCK_STRIPES = 256; private static final Object[] PURCHASE_LOCKS = createPurchaseLocks(); - private static final ReentrantReadWriteLock SHARED_MYSQL_CACHE_RESET_FENCE = - new ReentrantReadWriteLock(true); private static final int COMPLETION_PENDING = 0; private static final int COMPLETION_RUNNING = 1; private static final int COMPLETION_COMPENSATING = 2; @@ -516,23 +513,11 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo } static void withSharedMysqlCacheResetFence(Runnable action) { - var lock = SHARED_MYSQL_CACHE_RESET_FENCE.writeLock(); - lock.lock(); - try { - action.run(); - } finally { - lock.unlock(); - } + SharedMysqlCacheReconciler.withResetFence(action); } static void withSharedMysqlCacheDumpFence(Runnable action) { - var lock = SHARED_MYSQL_CACHE_RESET_FENCE.readLock(); - lock.lock(); - try { - action.run(); - } finally { - lock.unlock(); - } + SharedMysqlCacheReconciler.withCacheDumpFence(action); } /** Runs bounded stale-purchase recovery from the plugin lifecycle executor. */ diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index c5ff94bc3..03198aa7a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -36,6 +36,56 @@ import com.bencodez.votingplugin.VotingPluginMain; class SharedMysqlPointMutatorTest { + @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); From 36a5979c9e6ebcbb66c956105a16e75d7ae40c90 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:29:42 -0600 Subject: [PATCH 48/74] Document shared limit cache contract --- .../votingplugin/user/SharedMysqlCacheReconciler.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java index 471b8ff63..cd92f0235 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlCacheReconciler.java @@ -12,6 +12,14 @@ /** Invalidates only fields changed directly by a shared-MySQL mutation. */ public final class SharedMysqlCacheReconciler { + /* + * This fence is intentionally JVM-local. Cross-backend reset correctness does + * not depend on it: shared-MySQL VoteShopLimit setters enqueue=false, and + * UserDataCache.dump() persists only queued UserDataChange entries, not the + * read-through value map. Purchases and resets mutate limit columns through the + * epoch-serialized JDBC journal. The fence only orders this JVM's cache drains + * around its own reset transaction. + */ private static final ReentrantReadWriteLock RESET_FENCE = new ReentrantReadWriteLock(true); private static final Map> OPTIMISTIC_POINT_VALUES = java.util.Collections.synchronizedMap(new WeakHashMap<>()); From 64a983b34a17d61cee87e22ecd13ca30e3e29473 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 12 Sep 2026 21:40:03 -0600 Subject: [PATCH 49/74] Track epochs for every limited purchase --- .../voteshop/service/SharedMysqlPurchaseJournal.java | 7 +------ .../service/SharedMysqlPurchaseJournalTest.java | 10 ++++++++-- 2 files changed, 9 insertions(+), 8 deletions(-) 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 index 67834b427..e2c29d80d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -139,8 +139,7 @@ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limi try (Connection connection = connection()) { connection.setAutoCommit(false); try { - Long limitEpoch = tracksResetEpoch(limitColumn, limitGeneration) - ? lockLimitEpoch(connection, limitColumn) : null; + Long limitEpoch = limitColumn == null ? null : lockLimitEpoch(connection, limitColumn); try (PreparedStatement insertStatement = connection.prepareStatement(insert); PreparedStatement debitStatement = connection.prepareStatement(debit.toString())) { insertStatement.setString(1, purchaseId); @@ -606,10 +605,6 @@ private Connection connection() throws SQLException { private String qi(String identifier) { return table.qi(identifier); } private String uuidCast() { return table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"; } - private static boolean tracksResetEpoch(String limitColumn, String generation) { - return limitColumn != null && generation != null && !NO_LIMIT_RESET_GENERATION.equals(generation); - } - private long lockLimitEpoch(Connection connection, String limitColumn) throws SQLException { return lockLimitEpochRow(connection, limitColumn).epoch(); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index 355651a6e..b92a48431 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -41,17 +41,23 @@ void journalTableNameIsPortableAndCollisionResistantForLongSourceNames() { @Test void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { Fixture fixture = fixture(); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); PreparedStatement insert = mock(PreparedStatement.class); PreparedStatement debit = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(3L); + when(markerSelect.executeQuery()).thenReturn(epoch); when(debit.executeUpdate()).thenReturn(1); - when(fixture.work.prepareStatement(anyString())).thenReturn(insert, debit); + when(fixture.work.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, insert, debit); SharedMysqlPurchaseJournal journal = new SharedMysqlPurchaseJournal(fixture.table, false); assertTrue(journal.reserve("purchase-1", "player", "Points", "VoteShopLimitdaily", 10, 1, SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION, 0L, 100L)); verify(insert).setString(7, SharedMysqlPurchaseJournal.NO_LIMIT_RESET_GENERATION); - verify(insert).setNull(9, java.sql.Types.BIGINT); + verify(insert).setLong(9, 3L); verify(insert).setString(10, "PENDING"); verify(debit).setInt(1, 10); verify(fixture.work).commit(); From 03dd3fbdb8b0f8ff7e9c522c49329be678ff79c2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:44:43 -0600 Subject: [PATCH 50/74] Version Vote Party configuration support --- .../control/BackendConfigurationService.java | 4 ++-- .../control/BackendControlConnector.java | 15 ++++++++++----- .../control/BackendConfigurationServiceTest.java | 5 +++-- .../BackendControlConnectorProtocolTest.java | 15 +++++++++++---- docs/control-agent-contract.md | 3 ++- docs/control-connector.md | 3 ++- 6 files changed, 30 insertions(+), 15 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 68b7a267c..32d46731a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -393,7 +393,7 @@ private QuickProposal quickProposal(String preset, Map options, case "vote-logging" -> Set.of("enabled", "purgeDays", "useMainMySQL"); case "common-settings" -> Set.of("processRewards", "autoCreateVoteSites", "extraAllSitesCheck", "countFakeVotes", "disableNoServiceSiteMessage", "disableUpdateChecking"); - case "vote-party" -> Set.of("votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command"); + case "vote-party" -> Set.of("enabled", "votesRequired", "broadcast", "giveAllPlayers", "onlineOnly", "command"); case "sync-vote-sites" -> Set.of("sourceContent"); default -> throw new IllegalArgumentException("quick setup preset is unsupported"); }); @@ -497,7 +497,7 @@ private QuickProposal quickProposal(String preset, Map options, return new QuickProposal(fileName, yaml.saveToString()); } if ("vote-party".equals(preset)) { - yaml.set("VoteParty.Enabled", true); + yaml.set("VoteParty.Enabled", booleanOption(options, "enabled")); yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000)); yaml.set("VoteParty.GiveAllPlayers", booleanOption(options, "giveAllPlayers")); yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly")); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 58a5a3209..f1fd91643 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -48,7 +48,8 @@ public final class BackendControlConnector implements AutoCloseable { private static final long INSPECTION_SHUTDOWN_TIMEOUT_SECONDS = 5; private static final Pattern NODE_ID = Pattern.compile("[A-Za-z0-9][A-Za-z0-9._-]{0,63}"); private static final Set CAPABILITIES = Set.of("config.files.v1", "config.file-comments.v1", - "config.quick-setup.v1", "config.vote-sites-sync.v1", "config.proxy-method.v1", "data.inspect.v1"); + "config.quick-setup.v1", "config.quick-setup.v2", "config.vote-sites-sync.v1", + "config.proxy-method.v1", "data.inspect.v1"); private final VotingPluginMain plugin; private final Path dataDirectory; @@ -72,6 +73,7 @@ public final class BackendControlConnector implements AutoCloseable { private volatile boolean registered; private volatile boolean operationsAccepted; private volatile boolean quickSetupsAccepted; + private volatile boolean votePartySetupsAccepted; private volatile boolean voteSitesSyncAccepted; private volatile boolean inspectionsAccepted; private volatile int inspectionFailures; @@ -305,6 +307,7 @@ private void cycle() { } operationsAccepted = negotiatedCapability(node, "config.files.v1", operationsAccepted); quickSetupsAccepted = negotiatedCapability(node, "config.quick-setup.v1", quickSetupsAccepted); + votePartySetupsAccepted = negotiatedCapability(node, "config.quick-setup.v2", votePartySetupsAccepted); voteSitesSyncAccepted = negotiatedCapability(node, "config.vote-sites-sync.v1", voteSitesSyncAccepted); boolean inspectionsWereAccepted = inspectionsAccepted; inspectionsAccepted = negotiatedCapability(node, "data.inspect.v1", inspectionsAccepted); @@ -356,6 +359,7 @@ private JsonObject register() throws Exception { // A registration must explicitly establish required capabilities. Heartbeats may omit the unchanged set. operationsAccepted = false; quickSetupsAccepted = false; + votePartySetupsAccepted = false; voteSitesSyncAccepted = false; inspectionsAccepted = false; JsonObject body = sessionBody(); @@ -608,7 +612,6 @@ private TaskResult execute(UUID operationId, JsonObject task) { String domain = string(configuration, "domain"); if ("file".equals(domain)) return executeFile(operationId, type, configuration, task); if ("quick-setup".equals(domain)) { - if (!quickSetupsAccepted) return TaskResult.failure("UNSUPPORTED_TASK", "Quick setups were not negotiated"); return executeQuick(operationId, type, configuration, task); } return TaskResult.failure("UNSUPPORTED_TASK", "Configuration domain is unsupported"); @@ -681,8 +684,9 @@ private TaskResult executeFile(UUID operationId, String type, JsonObject configu private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task) throws IOException { String preset = string(configuration, "preset"); - if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, voteSitesSyncAccepted)) { - return TaskResult.failure("UNSUPPORTED_TASK", "VoteSites sync was not negotiated"); + if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, + voteSitesSyncAccepted)) { + return TaskResult.failure("UNSUPPORTED_TASK", "The required quick setup capability was not negotiated"); } Map options = options(configuration.getAsJsonObject("options")); if ("READ".equals(type)) { @@ -730,7 +734,8 @@ static List boundedResultChanges(List changes) { } static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, - boolean voteSitesSyncAccepted) { + boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted) { + if ("vote-party".equals(preset)) return votePartySetupsAccepted; return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted); } 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 e144b26a5..3ca3a447e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -501,9 +501,10 @@ 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")); } @Test void guidedSettingsReadTheInstalledValuesInsteadOfAssumingDefaults() throws Exception { @@ -607,7 +608,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 c0452d01d..9cdc6b00c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -84,6 +84,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 -> "data.inspect.v1".equals(value.getAsString()))); JsonArray required = registration.getAsJsonArray("requiredCapabilities"); @@ -117,10 +119,15 @@ 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)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false)); } @Test void rewardBuilderResultsKeepOnlyTheSafeRecoveryTarget() { diff --git a/docs/control-agent-contract.md b/docs/control-agent-contract.md index 8375e58f1..a26a689a1 100644 --- a/docs/control-agent-contract.md +++ b/docs/control-agent-contract.md @@ -3,7 +3,8 @@ This is the compact source of truth for an AI agent or Control client implementing the Bukkit integration. The connector has two separate lanes: -- configuration operations use the negotiated `config.files.v1` / `config.quick-setup.v1` contract and may write only +- configuration operations use the negotiated `config.files.v1` / `config.quick-setup.v1` contracts; Vote Party + proposals that include `enabled` additionally require `config.quick-setup.v2`, and configuration operations may write only managed VotingPlugin YAML after preview and approval; - inspections use the optional `data.inspect.v1` contract and are always read-only. diff --git a/docs/control-connector.md b/docs/control-connector.md index 4e797fef5..5420cf757 100644 --- a/docs/control-connector.md +++ b/docs/control-connector.md @@ -189,7 +189,8 @@ The Bukkit connector owns separate single-thread daemon executors for presence/c inspections, and performs no Control I/O on the server thread. The inspection worker is cancelled on shutdown with a bounded five-second wait, so a slow database read does not hold the configuration lane or shutdown indefinitely. The connector reports a bounded list of installed plugin names for WebUI command suggestions and negotiates -`config.files.v1`, `config.quick-setup.v1`, and the separate read-only `data.inspect.v1` capability. It polls configuration +`config.files.v1`, `config.quick-setup.v1`, the Vote Party Enabled extension `config.quick-setup.v2`, and the separate +read-only `data.inspect.v1` capability. It polls configuration operations and inspections over distinct outbound queues. Repeated inspection transport or protocol failures use bounded exponential backoff from one second to five minutes, while the configuration and voting paths remain available. File apply schedules the VotingPlugin reload on the Bukkit thread and waits only on the connector worker. Control failure never blocks votes, From d29b10784f7840e7e0a65115086f1f01a62056d7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:13:03 -0600 Subject: [PATCH 51/74] Harden replay recovery and mixed-version setup --- .../control/BackendConfigurationService.java | 5 +- .../control/BackendControlConnector.java | 14 ++- .../user/SharedMysqlPointMutator.java | 27 ++++- .../user/SharedPointAdditionJournal.java | 25 +++++ .../votingplugin/user/VotingPluginUser.java | 105 ++++++++++++++++-- .../voteshop/VoteShopManager.java | 14 ++- .../service/VoteShopPurchaseService.java | 1 - .../BackendConfigurationServiceTest.java | 4 + .../BackendControlConnectorProtocolTest.java | 9 ++ .../user/SharedMysqlPointMutatorTest.java | 16 +++ .../user/SharedPointAdditionJournalTest.java | 16 +++ .../VotingPluginUserPointSchedulingTest.java | 67 +++++++++++ .../voteshop/VoteShopManagerTest.java | 18 +++ .../service/VoteShopPurchaseServiceTest.java | 16 ++- 14 files changed, 308 insertions(+), 29 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 32d46731a..8768877a1 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -497,7 +497,10 @@ private QuickProposal quickProposal(String preset, Map options, return new QuickProposal(fileName, yaml.saveToString()); } if ("vote-party".equals(preset)) { - yaml.set("VoteParty.Enabled", booleanOption(options, "enabled")); + // v1 did not carry Enabled and historically enabled Vote Party when applied. + // v2 supplies the field so its actual state can round-trip unchanged. + yaml.set("VoteParty.Enabled", options.containsKey("enabled") + ? booleanOption(options, "enabled") : true); yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000)); yaml.set("VoteParty.GiveAllPlayers", booleanOption(options, "giveAllPlayers")); yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly")); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index f1fd91643..201e17aa6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -684,11 +684,11 @@ private TaskResult executeFile(UUID operationId, String type, JsonObject configu private TaskResult executeQuick(UUID operationId, String type, JsonObject configuration, JsonObject task) throws IOException { String preset = string(configuration, "preset"); + Map options = options(configuration.getAsJsonObject("options")); if (!quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, - voteSitesSyncAccepted)) { + voteSitesSyncAccepted, options)) { return TaskResult.failure("UNSUPPORTED_TASK", "The required quick setup capability was not negotiated"); } - Map options = options(configuration.getAsJsonObject("options")); if ("READ".equals(type)) { BackendConfigurationService.QuickState state = configurations.readQuickSetup(preset, options); return TaskResult.quick(preset, state.options(), state.revision(), List.of(), false); @@ -739,6 +739,16 @@ static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAc return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted); } + static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, + boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted, Map options) { + if ("vote-party".equals(preset)) { + return options != null && options.containsKey("enabled") + ? votePartySetupsAccepted : quickSetupsAccepted; + } + return quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, + voteSitesSyncAccepted); + } + private Response send(String method, String path, JsonObject body) throws Exception { HttpRequest request = HttpRequest.newBuilder(settings.endpoint().resolve(path)) .timeout(Duration.ofMillis(settings.requestTimeoutMillis())).header("Content-Type", "application/json") diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 0412f8b0a..c76b54cfe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -53,8 +53,19 @@ static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { * to the plugin lifecycle, so no independent task survives shutdown. */ static void scheduleTransferRecovery(VotingPluginMain plugin) { - plugin.getTimer().execute(() -> recoverSharedPointJournals(plugin)); - plugin.getTimer().scheduleWithFixedDelay(() -> recoverSharedPointJournals(plugin), 1L, 1L, TimeUnit.MINUTES); + plugin.getTimer().execute(() -> recoverSharedPointJournalsSafely(plugin)); + plugin.getTimer().scheduleWithFixedDelay(() -> recoverSharedPointJournalsSafely(plugin), 1L, 1L, + TimeUnit.MINUTES); + } + + private static void recoverSharedPointJournalsSafely(VotingPluginMain plugin) { + try { + recoverSharedPointJournals(plugin); + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to recover shared MySQL point journals: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } } private static void recoverSharedPointJournals(VotingPluginMain plugin) { @@ -154,6 +165,18 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { } } + /** Looks up a completed retry before its Bukkit receive event is dispatched. */ + Integer completedPointAdditionTotal(String operationId, String uuid, String pointsColumn) { + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .findCompleted(operationId, uuid, pointsColumn); + return result == null ? null : Integer.valueOf(result.total()); + } catch (SQLException failure) { + logFailure(failure); + throw new IllegalStateException("Unable to look up shared MySQL point addition", failure); + } + } + CompletionStage acknowledgePointAddition(String operationId) { if (!applies() || operationId == null || operationId.isEmpty()) { return CompletableFuture.completedFuture(null); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 8587b29af..b0af205a4 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -131,6 +131,27 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo } } + /** + * Returns a previously committed addition before a retry invokes its Bukkit + * receive hook. The amount deliberately remains part of {@link #add}: a + * listener may have adjusted it during the original invocation, so comparing + * it to a retry's pre-listener amount would make a completed operation look + * new. The immutable operation id is still bound to its player and points + * column before its total can be replayed. + */ + AdditionResult findCompleted(String operationId, String uuid, String pointsColumn) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + AdditionRow existing = find(operationId); + if (existing == null) return null; + if (!existing.matchesTarget(uuid, pointsColumn)) { + throw new SQLException("Mismatched shared point addition operation"); + } + if (!(COMPLETED.equals(existing.state) || ACKNOWLEDGED.equals(existing.state)) || existing.total == null) { + throw new SQLException("Shared point addition operation is not confirmable: " + operationId); + } + return new AdditionResult(existing.total.intValue()); + } + private AdditionResult existingResult(String operationId, AdditionRow row, String uuid, String pointsColumn, int amount) throws SQLException { if (!row.matches(uuid, pointsColumn, amount)) throw new SQLException("Mismatched shared point addition operation"); @@ -306,5 +327,9 @@ private record AdditionRow(String uuid, String pointsColumn, int amount, String boolean matches(String expectedUuid, String expectedPointsColumn, int expectedAmount) { return uuid.equals(expectedUuid) && pointsColumn.equals(expectedPointsColumn) && amount == expectedAmount; } + + boolean matchesTarget(String expectedUuid, String expectedPointsColumn) { + return uuid.equals(expectedUuid) && pointsColumn.equals(expectedPointsColumn); + } } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index d039490f9..7a6e9f4c9 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -17,6 +17,8 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.function.BiConsumer; import java.util.function.Consumer; import java.util.stream.Collectors; @@ -54,6 +56,8 @@ */ public class VotingPluginUser extends com.bencodez.advancedcore.api.user.AdvancedCoreUser { private static final int BULK_POINT_BATCH_SIZE = 64; + private static final ConcurrentMap> IN_FLIGHT_POINT_REPLAYS = + new ConcurrentHashMap<>(); /** The plugin instance. */ private VotingPluginMain plugin; @@ -280,17 +284,22 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu * @return committed point total, or an exceptional stage when persistence fails */ public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) { - PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); - Bukkit.getPluginManager().callEvent(event); - if (event.isCancelled()) { - return CompletableFuture.completedFuture(getPoints()); - } SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (!sharedPoints.applies()) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); int newTotal = getPoints() + event.getPoints(); setPoints(newTotal, false); return CompletableFuture.completedFuture(newTotal); } + if (operationId != null && !operationId.isEmpty()) { + return addSharedPointsWithReplayLookup(sharedPoints, value, operationId); + } + + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); CompletableFuture completion = new CompletableFuture<>(); try { @@ -311,6 +320,71 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu return completion; } + /** + * Checks the durable reward-operation journal before returning to the Bukkit + * lane for the receive event. JDBC therefore never blocks that lane, and a + * completed retry cannot invoke listeners a second time. + */ + private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPointMutator sharedPoints, int value, + String operationId) { + CompletableFuture completion = new CompletableFuture<>(); + String uuid = getUUID(); + String pointsPath = getPointsPath(); + ReplayPointKey replayKey = new ReplayPointKey(plugin, operationId, uuid, pointsPath); + CompletableFuture existing = IN_FLIGHT_POINT_REPLAYS.putIfAbsent(replayKey, completion); + if (existing != null) return existing; + completion.whenComplete((ignored, failure) -> IN_FLIGHT_POINT_REPLAYS.remove(replayKey, completion)); + // Entity lookup is Bukkit-owned, so retain the player before persistence + // submission just as callback-based point mutations do. + Player player = getPlayer(); + try { + plugin.getTimer().execute(() -> { + try { + Integer total = sharedPoints.completedPointAdditionTotal(operationId, uuid, pointsPath); + if (total != null) { + completion.complete(total.intValue()); + return; + } + BukkitCompletionScheduler.run(plugin, player, + () -> submitSharedPointAdditionAfterReplayLookup(sharedPoints, value, operationId, completion)); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + return completion; + } + + private record ReplayPointKey(VotingPluginMain plugin, String operationId, String uuid, String pointsPath) { } + + private void submitSharedPointAdditionAfterReplayLookup(SharedMysqlPointMutator sharedPoints, int value, + String operationId, CompletableFuture completion) { + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + completion.complete(getPoints()); + return; + } + try { + plugin.getTimer().execute(() -> { + try { + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints(), operationId); + if (result.success()) completion.complete(result.total()); + else completion.completeExceptionally( + new IllegalStateException("Unable to persist shared MySQL points")); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + } + /** Retires an idempotent point-addition record after its replay checkpoint is durable. */ public CompletionStage acknowledgeStorageAwarePointOperation(String operationId) { return new SharedMysqlPointMutator(plugin).acknowledgePointAddition(operationId); @@ -438,10 +512,15 @@ private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List players = new ArrayList<>(users.size()); + for (VotingPluginUser user : users) { + players.add(user.getPlayer()); + } + submitSharedMysqlChunk(plugin, users, players, 0, completion, sharedMutation); } - private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, int start, + private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, List players, + int start, BiConsumer completion, SharedPointMutation sharedMutation) { int end = Math.min(start + BULK_POINT_BATCH_SIZE, users.size()); Runnable persistenceWork = () -> { @@ -454,26 +533,28 @@ private static void submitSharedMysqlChunk(VotingPluginMain plugin, List users, int start, + private static void scheduleBulkCompletions(VotingPluginMain plugin, List users, List players, + int start, int end, boolean[] results, BiConsumer completion) { for (int index = start; index < end; index++) { VotingPluginUser user = users.get(index); + Player player = players.get(index); boolean success = results != null && results[index - start]; try { - BukkitCompletionScheduler.run(plugin, user.getPlayer(), () -> { + BukkitCompletionScheduler.run(plugin, player, () -> { try { completion.accept(user, success); } catch (RuntimeException failure) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java index 46ff729f2..f854f823a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/VoteShopManager.java @@ -52,9 +52,19 @@ private void startSharedPurchaseRecovery() { } static void scheduleSharedPurchaseRecovery(VotingPluginMain plugin) { - plugin.getTimer().execute(() -> VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin)); + plugin.getTimer().execute(() -> recoverSharedMysqlPurchasesSafely(plugin)); plugin.getTimer().scheduleWithFixedDelay( - () -> VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin), 1L, 1L, TimeUnit.MINUTES); + () -> recoverSharedMysqlPurchasesSafely(plugin), 1L, 1L, TimeUnit.MINUTES); + } + + private static void recoverSharedMysqlPurchasesSafely(VotingPluginMain plugin) { + try { + VoteShopPurchaseService.recoverSharedMysqlPurchases(plugin); + } catch (RuntimeException failure) { + plugin.getLogger().severe("Unable to recover shared MySQL vote shop purchases: " + + failure.getClass().getSimpleName()); + plugin.debug(failure); + } } /** 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 f8602ad0f..0f0d86cb3 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 @@ -605,7 +605,6 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo } try { SharedMysqlPurchaseJournal journal = SharedMysqlPurchaseJournal.forTable(table); - recoverSharedMysqlPurchases(plugin, journal); String purchaseId = UUID.randomUUID().toString(); if (journal.reserve(purchaseId, user.getUUID(), pointsColumn, limitColumn, item.getCost(), item.getLimit(), limitGeneration.value(), limitGeneration.expiresAt(), System.currentTimeMillis())) { 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 3ca3a447e..a7f142ca7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -505,6 +505,10 @@ class BackendConfigurationServiceTest { "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: true")); } @Test void guidedSettingsReadTheInstalledValuesInsteadOfAssumingDefaults() throws Exception { 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 9cdc6b00c..02df468f3 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -130,6 +130,15 @@ class BackendControlConnectorProtocolTest { assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, 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"))); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false, + Map.of("enabled", "true"))); + } + @Test void rewardBuilderResultsKeepOnlyTheSafeRecoveryTarget() { String proposal = "{\"scope\":\"site\",\"site\":\"PMC\",\"commands\":[\"secret command\"]}"; Map result = BackendControlConnector.resultQuickOptions("reward-builder", diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 03198aa7a..219e2998c 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -243,6 +243,22 @@ void rejectedRecoverySchedulingCanRetryLater() { 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); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 59ba929f2..8893401ce 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -76,6 +76,22 @@ void completedOperationRejectsAConflictingRetryInsteadOfChangingPoints() throws 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 distinctRewardOccurrencesCreditIndependentlyWhileRetryingOneDoesNot() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index c0f719319..c76c4fec6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -95,6 +95,36 @@ void rejectedSharedBulkMutationCompletesEveryUserAsFailed() throws Exception { 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(); @@ -378,6 +408,43 @@ void storageAwareAsyncStageWaitsForCommittedSharedWrite() throws Exception { 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 sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java index 7e816deef..0454b0521 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/VoteShopManagerTest.java @@ -5,6 +5,8 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doThrow; +import org.mockito.ArgumentCaptor; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; @@ -26,4 +28,20 @@ void schedulesStartupAndBoundedPeriodicSharedPurchaseRecovery() { verify(timer).scheduleWithFixedDelay(any(Runnable.class), anyLong(), anyLong(), org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); } + + @Test + void periodicRecoveryContainsRuntimeFailures() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + ScheduledExecutorService timer = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(timer); + doThrow(new IllegalStateException("storage unavailable")).when(plugin).getStorageType(); + + VoteShopManager.scheduleSharedPurchaseRecovery(plugin); + ArgumentCaptor scheduled = ArgumentCaptor.forClass(Runnable.class); + verify(timer).scheduleWithFixedDelay(scheduled.capture(), anyLong(), anyLong(), + org.mockito.ArgumentMatchers.eq(TimeUnit.MINUTES)); + org.junit.jupiter.api.Assertions.assertDoesNotThrow(scheduled.getValue()::run); + verify(plugin.getLogger()).severe( + "Unable to recover shared MySQL vote shop purchases: IllegalStateException"); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index e50914c8e..f0ceb5232 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -377,8 +377,7 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw 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(schemaConnection, pendingConnection, - compensatingConnection, cleanupConnection, debitConnection, refundConnection); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, debitConnection, refundConnection); when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, schemaGenerationExpiry, schemaIndex); when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); @@ -438,7 +437,7 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw org.mockito.ArgumentMatchers.eq(player), scheduled.capture(), retirement.capture()); purchase.get(5, TimeUnit.SECONDS); ArgumentCaptor compensation = ArgumentCaptor.forClass(Runnable.class); - verify(persistenceExecutor, times(2)).execute(compensation.capture()); + verify(persistenceExecutor, org.mockito.Mockito.timeout(1000).times(2)).execute(compensation.capture()); compensation.getAllValues().get(1).run(); ArgumentCaptor refundSql = ArgumentCaptor.forClass(String.class); @@ -446,10 +445,9 @@ void sharedMysqlDebitIsRefundedWhenEntitySchedulerRetiresWithoutFallback() throw assertTrue(refundSql.getAllValues().get(2).contains("`Points` = `Points` + ?")); verify(refund).setInt(1, 10); verify(refund, times(1)).executeUpdate(); - // Schema, stale cleanup, compensating cleanup, terminal cleanup, reservation, - // and refund are the only database connections in the scheduler-retirement path. An eighth - // checkout would be the reward claim and would make the debit unrecoverable. - verify(sql.getConnectionManager(), times(7)).getConnection(); + // Schema, reservation, cache refresh, and refund are the only database connections in the + // scheduler-retirement path. Another checkout would be the reward claim and would make the debit unrecoverable. + verify(sql.getConnectionManager(), times(4)).getConnection(); verify(entityScheduler, times(2)).runAtEntityWithFallback( org.mockito.ArgumentMatchers.eq(player), any(), any(Runnable.class)); ArgumentCaptor fallbackCompletion = ArgumentCaptor.forClass(Runnable.class); @@ -1076,8 +1074,8 @@ void sharedPurchaseKeepsRewardConfigurationFromBeforeShopReload() throws Excepti 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(schemaConnection, pendingConnection, - compensatingConnection, cleanupConnection, reserveConnection, claimConnection, completeConnection); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, reserveConnection, + claimConnection, completeConnection); when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, schemaGeneration, schemaGenerationExpiry, schemaIndex); when(pendingConnection.prepareStatement(anyString())).thenReturn(pending); From 044bef34290b4a28a3e0d4f90802a15de44976fb Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:33:41 -0600 Subject: [PATCH 52/74] Preserve mixed-version Vote Party settings --- AGENTS.md | 12 ++++++++++-- .../control/BackendConfigurationService.java | 15 ++++++++++----- .../control/BackendControlConnector.java | 13 ++++++++++++- .../control/BackendConfigurationServiceTest.java | 6 +++++- .../BackendControlConnectorProtocolTest.java | 5 +++++ 5 files changed, 42 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index a7f56a822..b3ae04790 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,8 +76,8 @@ automation because it copies a JAR into a developer-specific server directory. Keep these paths separate: - discovery/presence advertises current node identity and topology; -- configuration capabilities (`config.*.v1`) poll `/operations`, may read/preview/apply typed configuration, and journal - results; +- configuration capabilities (`config.*.v1` plus explicitly negotiated successors) poll `/operations`, may + read/preview/apply typed configuration, and journal results; - inspection capability `data.inspect.v1` polls `/inspections`, executes only `ControlInspectionService`, and does not journal because a lost acknowledgement can safely repeat a read. Repeated failures back this lane off exponentially from one second to five minutes without changing voting or configuration availability. @@ -160,6 +160,14 @@ limit: Prefer one cohesive PR per repository for a paired feature, keeping its implementation, tests, and docs together. Split further only when a part is independently deployable or has materially different review/rollback risk. +`config.proxy-method.v1` covers plugin messaging and Redis; `config.proxy-method.v2` adds HTTP. Dispatch and validate the +exact capability for the requested method. `config.quick-setup.v2` adds `VoteParty.Enabled`; keep legacy Vote Party +payloads on v1, preserve the installed Enabled value when they omit it, and reject the `enabled` field unless v2 was +accepted. The VotingPlugin connector may deploy first and +advertise these successors without using them until Control accepts them. A newer Control deployed first must leave its +v2-only actions unavailable on older nodes. Merge the VotingPlugin capability implementation before relying on the new +Control behavior in production. + Before pushing, run the focused tests, the full Maven build, and `git diff --check`. Do not commit server runtime data, credentials, generated JARs, dependency caches, IDE output, or unrelated formatting. diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index 8768877a1..bf177407c 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -227,8 +227,12 @@ public QuickState readQuickSetup(String preset, Map options) thr if (!READABLE_QUICK_SETUPS.contains(preset)) { throw new IllegalArgumentException("quick setup preset cannot be read"); } - rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") : Set.of()); + rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") + : "vote-party".equals(preset) ? Set.of("enabled") : Set.of()); if ("vote-site".equals(preset)) option(options, "name", "[A-Za-z0-9_-]{1,64}"); + if ("vote-party".equals(preset) && options.containsKey("enabled")) { + booleanOption(options, "enabled"); + } return retryRead(() -> readQuickSetupOnce(preset, options)); } @@ -497,10 +501,11 @@ private QuickProposal quickProposal(String preset, Map options, return new QuickProposal(fileName, yaml.saveToString()); } if ("vote-party".equals(preset)) { - // v1 did not carry Enabled and historically enabled Vote Party when applied. - // v2 supplies the field so its actual state can round-trip unchanged. - yaml.set("VoteParty.Enabled", options.containsKey("enabled") - ? booleanOption(options, "enabled") : true); + // v1 does not carry Enabled, so it must leave the installed value untouched. + // v2 supplies the field so its actual state can round-trip explicitly. + if (options.containsKey("enabled")) { + yaml.set("VoteParty.Enabled", booleanOption(options, "enabled")); + } yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000)); yaml.set("VoteParty.GiveAllPlayers", booleanOption(options, "giveAllPlayers")); yaml.set("VoteParty.GiveOnlinePlayersOnly", booleanOption(options, "onlineOnly")); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 201e17aa6..556a85194 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -691,7 +691,8 @@ private TaskResult executeQuick(UUID operationId, String type, JsonObject config } if ("READ".equals(type)) { BackendConfigurationService.QuickState state = configurations.readQuickSetup(preset, options); - return TaskResult.quick(preset, state.options(), state.revision(), List.of(), false); + return TaskResult.quick(preset, resultQuickReadOptions(preset, state.options(), options), + state.revision(), List.of(), false); } if ("PREVIEW".equals(type)) { BackendConfigurationService.QuickPreview preview = configurations.previewQuickSetup(preset, options); @@ -826,6 +827,16 @@ static Map resultQuickOptions(String preset, Map return options == null ? Map.of() : Map.copyOf(options); } + static Map resultQuickReadOptions(String preset, Map state, + Map requestOptions) { + if (!"vote-party".equals(preset) || requestOptions != null && requestOptions.containsKey("enabled")) { + return resultQuickOptions(preset, state); + } + Map legacy = new LinkedHashMap<>(state); + legacy.remove("enabled"); + return Map.copyOf(legacy); + } + private static int bounded(int value, int min, int max, String name) { if (value < min || value > max) throw new IllegalArgumentException("Control.Backend." + name + " is invalid"); return value; 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 a7f142ca7..c73db2367 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendConfigurationServiceTest.java @@ -508,7 +508,7 @@ class BackendConfigurationServiceTest { BackendConfigurationService.QuickPreview legacyParty = service.previewQuickSetup("vote-party", Map.of( "votesRequired", "25", "command", "", "broadcast", "", "giveAllPlayers", "false", "onlineOnly", "true")); - assertTrue(legacyParty.proposal().content().contains("Enabled: true")); + assertTrue(legacyParty.proposal().content().contains("Enabled: false")); } @Test void guidedSettingsReadTheInstalledValuesInsteadOfAssumingDefaults() throws Exception { @@ -532,6 +532,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 { 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 02df468f3..796e3e8cf 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -137,6 +137,11 @@ class BackendControlConnectorProtocolTest { Map.of("enabled", "true"))); assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, 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() { From 17080c041bd8899e5adc843af2dad4642732ab33 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 18:03:39 -0600 Subject: [PATCH 53/74] Harden shared point mutation recovery --- .../votingplugin/commands/CommandLoader.java | 34 +- .../control/BackendControlConnector.java | 4 +- .../user/SharedMysqlPointMutator.java | 32 ++ .../user/SharedPointAdditionJournal.java | 293 +++++++++++++++++- .../votingplugin/user/VotingPluginUser.java | 101 ++++-- .../util/BukkitCompletionScheduler.java | 16 +- .../commands/CommandLoaderSchedulingTest.java | 18 ++ .../BackendControlConnectorProtocolTest.java | 7 +- .../user/SharedPointAdditionJournalTest.java | 172 +++++++++- 9 files changed, 623 insertions(+), 54 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index c11454f0d..0254509eb 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -522,20 +522,26 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { VotingPluginUser user = plugin.getVotingPluginUserManager().getVotingPluginUser(args[1]); user.cache(); int amount = Integer.parseInt(args[3]); - user.addPointsStorageAware(amount, (success, newTotal) -> { - if (!success) { - runForCommandSender(sender, () -> sender.sendMessage( - MessageAPI.colorize("&cUnable to add " + args[3] + " points to " + args[1]))); - return; - } - if (user.isOnline()) { - user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), - "amount", args[3]); - } - runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] - + " " + args[3] + " points" + ", " + args[1] + " now has " + newTotal + " points"))); - plugin.getPlaceholders().onUpdate(user, false); - }); + String operationId = "admin-points/" + UUID.randomUUID(); + user.addPointsStorageAwareAsync(amount, operationId).whenComplete((newTotal, failure) -> + runForVotingUser(user, () -> { + if (failure != null) { + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( + "&cUnable to confirm the point addition; do not retry this command"))); + return; + } + if (user.isOnline()) { + user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), + "amount", args[3]); + } + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] + + " " + args[3] + " points" + ", " + args[1] + " now has " + newTotal + " points"))); + plugin.getPlaceholders().onUpdate(user, false); + user.acknowledgeStorageAwarePointOperation(operationId) + .whenComplete((ignored, acknowledgementFailure) -> { + if (acknowledgementFailure != null) plugin.debug(acknowledgementFailure); + }); + })); } }); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java index 556a85194..dfa57ce8a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendControlConnector.java @@ -736,7 +736,7 @@ static List boundedResultChanges(List changes) { static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAccepted, boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted) { - if ("vote-party".equals(preset)) return votePartySetupsAccepted; + if ("vote-party".equals(preset)) return quickSetupsAccepted && votePartySetupsAccepted; return quickSetupsAccepted && (!"sync-vote-sites".equals(preset) || voteSitesSyncAccepted); } @@ -744,7 +744,7 @@ static boolean quickSetupCapabilityAccepted(String preset, boolean quickSetupsAc boolean votePartySetupsAccepted, boolean voteSitesSyncAccepted, Map options) { if ("vote-party".equals(preset)) { return options != null && options.containsKey("enabled") - ? votePartySetupsAccepted : quickSetupsAccepted; + ? quickSetupsAccepted && votePartySetupsAccepted : quickSetupsAccepted; } return quickSetupCapabilityAccepted(preset, quickSetupsAccepted, votePartySetupsAccepted, voteSitesSyncAccepted); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index c76b54cfe..c311cef05 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -177,6 +177,38 @@ Integer completedPointAdditionTotal(String operationId, String uuid, String poin } } + SharedPointAdditionJournal.HookClaim claimPointAdditionHook(String operationId, String uuid, String pointsColumn, + int requestedAmount, String owner) { + try { + return SharedPointAdditionJournal.forTable(plugin.getMysql()).claimHook(operationId, uuid, pointsColumn, + requestedAmount, owner, System.currentTimeMillis()); + } catch (SQLException failure) { + logFailure(failure); + throw new IllegalStateException("Unable to claim shared MySQL point addition", failure); + } + } + + void markPointAdditionIndeterminate(String operationId, String uuid, String pointsColumn, int requestedAmount, + String owner) throws SQLException { + SharedPointAdditionJournal.forTable(plugin.getMysql()).markIndeterminate(operationId, uuid, pointsColumn, + requestedAmount, owner); + } + + AddResult settleClaimedPointAddition(VotingPluginUser user, String operationId, String uuid, String pointsColumn, + int requestedAmount, String owner, Integer adjustedAmount) { + drainCache(user); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .settleClaim(operationId, uuid, pointsColumn, requestedAmount, owner, adjustedAmount); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(false, 0); + } finally { + discardPointsCache(user, pointsColumn); + } + } + CompletionStage acknowledgePointAddition(String operationId) { if (!applies() || operationId == null || operationId.isEmpty()) { return CompletableFuture.completedFuture(null); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index b0af205a4..158c69337 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -27,8 +27,17 @@ final class SharedPointAdditionJournal { private static final String COMPLETED = "COMPLETED"; private static final String ACKNOWLEDGED = "ACKNOWLEDGED"; + /* A claimed hook is never replayed automatically: arbitrary listeners may + * already have produced side effects before a backend stops. */ + private static final String HOOK_STARTED = "HOOK_STARTED"; + /* The receive hook may have run, but no durable credit/cancellation outcome + * was confirmed. This state is deliberately never replayed automatically. */ + private static final String INDETERMINATE = "INDETERMINATE"; /* Matches the bounded durable reconciliation window used by shared transfers. */ static final long COMPLETED_RETENTION_MILLIS = TimeUnit.DAYS.toMillis(7); + /* A live receive hook normally settles immediately. Do not let a replacement + * backend preempt it; only a bounded, expired foreign claim is recoverable. */ + static final long HOOK_RECOVERY_LEASE_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final int CLEANUP_BATCH_SIZE = 100; private static final int MAX_IDENTIFIER_BYTES = 63; private static final String JOURNAL_SUFFIX = "_PointAdditions"; @@ -72,8 +81,8 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo if (existing != null) return existingResult(operationId, existing, uuid, pointsColumn, amount); String insert = "INSERT INTO " + qiJournal() + " (" + qi("operation_id") + ", " + qi("player_uuid") - + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("state") + ", " - + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?)"; + + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("requested_amount") + ", " + + qi("state") + ", " + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; String points = qi(pointsColumn); String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + " + ? WHERE " + qi("uuid") + uuidCast(); @@ -90,9 +99,10 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo insertStatement.setString(2, uuid); insertStatement.setString(3, pointsColumn); insertStatement.setInt(4, amount); - insertStatement.setString(5, COMPLETED); - insertStatement.setNull(6, java.sql.Types.INTEGER); - insertStatement.setLong(7, now); + insertStatement.setInt(5, amount); + insertStatement.setString(6, COMPLETED); + insertStatement.setNull(7, java.sql.Types.INTEGER); + insertStatement.setLong(8, now); insertStatement.executeUpdate(); updateStatement.setInt(1, amount); @@ -131,6 +141,245 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo } } + /** + * Claims an idempotent reward operation before its arbitrary Bukkit receive + * hook runs. The claim is shared by all backend JVMs, unlike the caller's + * process-local coalescing map. + */ + HookClaim claimHook(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner, + long now) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + AdditionRow existing = find(operationId); + if (existing != null) { + if (isExpiredForeignHook(existing, owner, now) + && markExpiredHookIndeterminate(operationId, existing, uuid, pointsColumn, requestedAmount, now)) { + return HookClaim.reconciliationRequired(); + } + return claimForExisting(existing, uuid, pointsColumn, requestedAmount, owner); + } + + String insert = "INSERT INTO " + qiJournal() + " (" + qi("operation_id") + ", " + qi("player_uuid") + + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("requested_amount") + ", " + + qi("state") + ", " + qi("total_points") + ", " + qi("hook_owner") + ", " + + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(insert)) { + connection.setAutoCommit(false); + statement.setString(1, operationId); + statement.setString(2, uuid); + statement.setString(3, pointsColumn); + statement.setInt(4, requestedAmount); + statement.setInt(5, requestedAmount); + statement.setString(6, HOOK_STARTED); + statement.setNull(7, java.sql.Types.INTEGER); + statement.setString(8, owner); + statement.setLong(9, now); + statement.executeUpdate(); + try { + connection.commit(); + return HookClaim.claimedByCaller(); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AdditionRow confirmed = find(operationId); + if (confirmed != null) return claimForExisting(confirmed, uuid, pointsColumn, requestedAmount, owner); + throw ambiguousCommit; + } + } catch (SQLException failure) { + if (!isDuplicate(failure)) throw failure; + AdditionRow duplicate = find(operationId); + if (duplicate != null) return claimForExisting(duplicate, uuid, pointsColumn, requestedAmount, owner); + throw failure; + } + } + + /** Completes a claimed hook exactly once, including a durable cancellation outcome. */ + AdditionResult settleClaim(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner, + Integer adjustedAmount) throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ? FOR UPDATE"; + String points = qi(pointsColumn); + String updatePoints = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + + " + ? WHERE " + qi("uuid") + uuidCast(); + String readPoints = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + + uuidCast(); + String complete = "UPDATE " + qiJournal() + " SET " + qi("amount") + " = ?, " + qi("state") + + " = ?, " + qi("total_points") + " = ? WHERE " + qi("operation_id") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + AdditionRow row; + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point addition claim disappeared"); + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), + total, requested, result.getString(7), result.getLong(8)); + } + HookClaim resolved = claimForExisting(row, uuid, pointsColumn, requestedAmount, owner); + if (resolved.completed()) { + rollback(connection); + return new AdditionResult(resolved.total()); + } + if (!resolved.claimed()) { + rollback(connection); + throw new SQLException("Shared point addition claim is not owned by this operation"); + } + } + if (adjustedAmount != null) { + try (PreparedStatement statement = connection.prepareStatement(updatePoints)) { + statement.setInt(1, adjustedAmount.intValue()); + statement.setString(2, uuid); + if (statement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point user row missing"); + } + } + } + int total; + try (PreparedStatement statement = connection.prepareStatement(readPoints)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point user row disappeared"); + } + total = result.getInt(1); + } + } + try (PreparedStatement statement = connection.prepareStatement(complete)) { + // amount is a durable, non-null actual credit. A cancelled hook has + // no credit, rather than a nullable/ambiguous amount. + statement.setInt(1, adjustedAmount == null ? 0 : adjustedAmount.intValue()); + statement.setString(2, COMPLETED); + statement.setInt(3, total); + statement.setString(4, operationId); + if (statement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point addition journal row missing"); + } + } + try { + connection.commit(); + return new AdditionResult(total); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AdditionRow confirmed = find(operationId); + HookClaim resolved = confirmed == null ? null + : claimForExisting(confirmed, uuid, pointsColumn, requestedAmount, owner); + if (resolved != null && resolved.completed()) return new AdditionResult(resolved.total()); + throw ambiguousCommit; + } + } + } + + private HookClaim claimForExisting(AdditionRow row, String uuid, String pointsColumn, int requestedAmount, + String owner) throws SQLException { + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + throw new SQLException("Mismatched shared point addition operation"); + } + if ((COMPLETED.equals(row.state) || ACKNOWLEDGED.equals(row.state)) && row.total != null) { + return HookClaim.completed(row.total.intValue()); + } + if (HOOK_STARTED.equals(row.state) && owner.equals(row.hookOwner)) return HookClaim.claimedByCaller(); + if (HOOK_STARTED.equals(row.state) || INDETERMINATE.equals(row.state)) { + return HookClaim.reconciliationRequired(); + } + return HookClaim.inProgress(); + } + + private boolean isExpiredForeignHook(AdditionRow row, String owner, long now) { + return HOOK_STARTED.equals(row.state) && !owner.equals(row.hookOwner) + && row.createdAt <= now - HOOK_RECOVERY_LEASE_MILLIS; + } + + /** + * Atomically changes only an expired foreign hook claim. A current owner can + * settle while its lease is live; after expiry we retain the operation for + * manual reconciliation instead of replaying an arbitrary hook. + */ + private boolean markExpiredHookIndeterminate(String operationId, AdditionRow row, String uuid, String pointsColumn, + int requestedAmount, long now) throws SQLException { + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + throw new SQLException("Mismatched shared point addition operation"); + } + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("operation_id") + + " = ? AND " + qi("state") + " = ? AND " + qi("created_at") + " <= ?"; + try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(update)) { + connection.setAutoCommit(false); + statement.setString(1, INDETERMINATE); + statement.setString(2, operationId); + statement.setString(3, HOOK_STARTED); + statement.setLong(4, now - HOOK_RECOVERY_LEASE_MILLIS); + if (statement.executeUpdate() != 1) { + rollback(connection); + return false; + } + connection.commit(); + return true; + } + } + + /** + * Records that a claimed receive hook cannot be safely retried. This is a + * durable operator-facing distinction from a live claim: a later retry must + * not wait for an owner that can no longer settle it, or invoke listeners + * again. + */ + void markIndeterminate(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner) + throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ? FOR UPDATE"; + String update = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ? WHERE " + qi("operation_id") + + " = ? AND " + qi("state") + " = ? AND " + qi("hook_owner") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point addition claim disappeared"); + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + AdditionRow row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), + result.getString(4), total, requested, result.getString(7), result.getLong(8)); + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + rollback(connection); + throw new SQLException("Mismatched shared point addition operation"); + } + if (!HOOK_STARTED.equals(row.state)) { + rollback(connection); + return; + } + } + } + try (PreparedStatement updateStatement = connection.prepareStatement(update)) { + updateStatement.setString(1, INDETERMINATE); + updateStatement.setString(2, operationId); + updateStatement.setString(3, HOOK_STARTED); + updateStatement.setString(4, owner); + if (updateStatement.executeUpdate() != 1) { + rollback(connection); + return; + } + } + connection.commit(); + } + } + /** * Returns a previously committed addition before a retry invokes its Bukkit * receive hook. The amount deliberately remains part of {@link #add}: a @@ -212,14 +461,17 @@ private void commitAndConfirm(Connection connection, String operationId) throws private AdditionRow find(String operationId) throws SQLException { String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") - + ", " + qi("state") + ", " + qi("total_points") + " FROM " + qiJournal() + " WHERE " + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ?"; try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(select)) { statement.setString(1, operationId); try (ResultSet result = statement.executeQuery()) { if (!result.next()) return null; Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); - return new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), total); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + return new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), total, + requested, result.getString(7), result.getLong(8)); } } } @@ -228,14 +480,26 @@ private void ensureSchema() throws SQLException { String create = "CREATE TABLE IF NOT EXISTS " + qiJournal() + " (" + qi("operation_id") + " VARCHAR(64) NOT NULL, " + qi("player_uuid") + " VARCHAR(37) NOT NULL, " + qi("points_column") + " VARCHAR(128) NOT NULL, " + qi("amount") + " INT NOT NULL, " - + qi("state") + " VARCHAR(16) NOT NULL, " + qi("total_points") + " INT NULL, " + + qi("requested_amount") + " INT NULL, " + qi("state") + " VARCHAR(16) NOT NULL, " + + qi("total_points") + " INT NULL, " + qi("hook_owner") + " VARCHAR(64) NULL, " + qi("created_at") + " BIGINT NOT NULL, PRIMARY KEY (" + qi("operation_id") + "));"; try (Connection connection = connection(); PreparedStatement statement = connection.prepareStatement(create)) { statement.executeUpdate(); + addColumnIfMissing(connection, "requested_amount", "INT NULL"); + addColumnIfMissing(connection, "hook_owner", "VARCHAR(64) NULL"); createIndex(connection); } } + private void addColumnIfMissing(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 (!isDuplicateColumn(failure)) throw failure; + } + } + private void createIndex(Connection connection) throws SQLException { String indexName = "vp_pa_" + Integer.toUnsignedString(journalTable.hashCode(), 36) + "_state_created"; String create = "CREATE INDEX " + (table.getDbType() == DbType.POSTGRESQL ? "IF NOT EXISTS " : "") @@ -307,6 +571,10 @@ private static boolean isDuplicateIndex(SQLException failure) { return failure.getErrorCode() == 1061 || "42P07".equals(failure.getSQLState()); } + private static boolean isDuplicateColumn(SQLException failure) { + return failure.getErrorCode() == 1060 || "42701".equals(failure.getSQLState()); + } + private static final class IdentityWeakReference extends WeakReference { private final int identityHash; @@ -323,7 +591,14 @@ private static final class IdentityWeakReference extends WeakReference { } record AdditionResult(int total) {} - private record AdditionRow(String uuid, String pointsColumn, int amount, String state, Integer total) { + record HookClaim(boolean claimed, boolean completed, boolean requiresReconciliation, int total) { + static HookClaim claimedByCaller() { return new HookClaim(true, false, false, 0); } + static HookClaim completed(int total) { return new HookClaim(false, true, false, total); } + static HookClaim reconciliationRequired() { return new HookClaim(false, false, true, 0); } + static HookClaim inProgress() { return new HookClaim(false, false, false, 0); } + } + private record AdditionRow(String uuid, String pointsColumn, int amount, String state, Integer total, + Integer requestedAmount, String hookOwner, long createdAt) { boolean matches(String expectedUuid, String expectedPointsColumn, int expectedAmount) { return uuid.equals(expectedUuid) && pointsColumn.equals(expectedPointsColumn) && amount == expectedAmount; } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 7a6e9f4c9..cca17d68d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -1,6 +1,7 @@ package com.bencodez.votingplugin.user; -import java.text.SimpleDateFormat; +import java.text.SimpleDateFormat; +import java.sql.SQLException; import java.time.Duration; import java.time.Instant; import java.time.LocalDate; @@ -321,32 +322,53 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu } /** - * Checks the durable reward-operation journal before returning to the Bukkit - * lane for the receive event. JDBC therefore never blocks that lane, and a - * completed retry cannot invoke listeners a second time. + * Claims the durable reward-operation journal before returning to the Bukkit + * lane for the receive event. JDBC therefore never blocks that lane, and the + * claim prevents another shared-MySQL backend from invoking listeners again. */ private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPointMutator sharedPoints, int value, String operationId) { CompletableFuture completion = new CompletableFuture<>(); String uuid = getUUID(); String pointsPath = getPointsPath(); - ReplayPointKey replayKey = new ReplayPointKey(plugin, operationId, uuid, pointsPath); + ReplayPointKey replayKey = new ReplayPointKey(plugin, operationId, uuid, pointsPath, value); CompletableFuture existing = IN_FLIGHT_POINT_REPLAYS.putIfAbsent(replayKey, completion); if (existing != null) return existing; completion.whenComplete((ignored, failure) -> IN_FLIGHT_POINT_REPLAYS.remove(replayKey, completion)); // Entity lookup is Bukkit-owned, so retain the player before persistence // submission just as callback-based point mutations do. Player player = getPlayer(); + String claimOwner = java.util.UUID.randomUUID().toString(); try { plugin.getTimer().execute(() -> { try { - Integer total = sharedPoints.completedPointAdditionTotal(operationId, uuid, pointsPath); - if (total != null) { - completion.complete(total.intValue()); + SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid, + pointsPath, value, claimOwner); + if (claim.completed()) { + completion.complete(claim.total()); + return; + } + if (!claim.claimed()) { + if (claim.requiresReconciliation()) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, null); + } else { + completion.completeExceptionally(new IllegalStateException( + "Shared MySQL point addition is already being confirmed")); + } return; } BukkitCompletionScheduler.run(plugin, player, - () -> submitSharedPointAdditionAfterReplayLookup(sharedPoints, value, operationId, completion)); + () -> { + try { + submitSharedPointAdditionAfterReplayLookup(sharedPoints, value, operationId, uuid, + pointsPath, claimOwner, completion); + } catch (Throwable failure) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, failure); + } + }, () -> reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, null)); } catch (Throwable failure) { completion.completeExceptionally(failure); } @@ -358,30 +380,67 @@ private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPoin return completion; } - private record ReplayPointKey(VotingPluginMain plugin, String operationId, String uuid, String pointsPath) { } + /** + * A receive hook can have arbitrary effects outside the points table. Once it + * was claimed, a scheduler or lifecycle failure must therefore remain a + * durable reconciliation task instead of being retried on another backend. + * This method never performs JDBC on the Bukkit/entity lane. + */ + private void reportIndeterminateSharedPointAddition(SharedMysqlPointMutator sharedPoints, String operationId, + String uuid, String pointsPath, int requestedAmount, String claimOwner, CompletableFuture completion, + Throwable cause) { + String message = "Shared MySQL point addition " + operationId + + " requires manual reconciliation; its receive hook will not be replayed"; + plugin.getLogger().severe(message); + if (cause != null) plugin.debug(cause); + completion.completeExceptionally(new IllegalStateException(message, cause)); + Runnable persist = () -> { + try { + sharedPoints.markPointAdditionIndeterminate(operationId, uuid, pointsPath, requestedAmount, claimOwner); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to persist indeterminate shared MySQL point addition " + operationId + + "; the existing claim remains for manual reconciliation"); + plugin.debug(failure); + } + }; + try { + plugin.getTimer().execute(persist); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, persist); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + // HOOK_STARTED is itself recognized as reconciliation-required after a + // restart, so rejected lifecycle executors cannot make a retry unsafe. + } + } + } + + private record ReplayPointKey(VotingPluginMain plugin, String operationId, String uuid, String pointsPath, + int requestedAmount) { } private void submitSharedPointAdditionAfterReplayLookup(SharedMysqlPointMutator sharedPoints, int value, - String operationId, CompletableFuture completion) { + String operationId, String uuid, String pointsPath, String claimOwner, CompletableFuture completion) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); - if (event.isCancelled()) { - completion.complete(getPoints()); - return; - } try { plugin.getTimer().execute(() -> { try { - SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints(), operationId); + SharedMysqlPointMutator.AddResult result = sharedPoints.settleClaimedPointAddition(this, operationId, + uuid, pointsPath, value, claimOwner, + event.isCancelled() ? null : Integer.valueOf(event.getPoints())); if (result.success()) completion.complete(result.total()); - else completion.completeExceptionally( - new IllegalStateException("Unable to persist shared MySQL points")); + else reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, new IllegalStateException("Unable to persist shared MySQL points")); } catch (Throwable failure) { - completion.completeExceptionally(failure); + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion, failure); } }); } catch (RuntimeException rejected) { - plugin.debug(rejected); - completion.completeExceptionally(rejected); + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, claimOwner, + completion, rejected); } } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java index ba8145880..f5135b76a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/util/BukkitCompletionScheduler.java @@ -14,17 +14,26 @@ private BukkitCompletionScheduler() { } public static void run(VotingPluginMain plugin, Player player, Runnable task) { + run(plugin, player, task, () -> { }); + } + + /** + * Schedules completion work and invokes {@code rejected} only after every + * entity/global fallback was rejected before the task began. Callers whose + * work has a durable pre-scheduler claim use this to record a recovery state. + */ + public static void run(VotingPluginMain plugin, Player player, Runnable task, Runnable rejected) { AtomicBoolean executed = new AtomicBoolean(); Runnable once = () -> { if (executed.compareAndSet(false, true)) task.run(); }; if (player == null) { - runGlobal(plugin, once); + runGlobal(plugin, once, rejected); return; } AtomicBoolean fallbackSubmitted = new AtomicBoolean(); Runnable fallback = () -> { - if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, once); + if (fallbackSubmitted.compareAndSet(false, true)) runGlobal(plugin, once, rejected); }; try { if (plugin.getBukkitScheduler().getFoliaLib() == null) { @@ -59,11 +68,12 @@ private static void runLegacyEntity(VotingPluginMain plugin, Player player, Runn } } - private static void runGlobal(VotingPluginMain plugin, Runnable task) { + private static void runGlobal(VotingPluginMain plugin, Runnable task, Runnable rejected) { try { plugin.getBukkitScheduler().runTask(plugin, task); } catch (RuntimeException schedulingFailure) { plugin.debug(schedulingFailure); + rejected.run(); } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java index 2e0d3b8ee..77f85e4a1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/commands/CommandLoaderSchedulingTest.java @@ -4,10 +4,12 @@ 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; @@ -21,6 +23,7 @@ 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 @@ -102,6 +105,21 @@ void transferFailureMessagesDoNotDiagnoseAvailabilityAsInsufficientPoints() { 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); 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 796e3e8cf..5ca415144 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/control/BackendControlConnectorProtocolTest.java @@ -127,7 +127,8 @@ class BackendControlConnectorProtocolTest { @Test void votePartyRequiresItsVersionedCapability() { assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false)); - assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false)); + assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false)); + assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, true, false)); } @Test void legacyVotePartyOptionsUseV1ButEnabledRequiresV2() { @@ -135,7 +136,9 @@ class BackendControlConnectorProtocolTest { Map.of("threshold", "10"))); assertFalse(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", true, false, false, Map.of("enabled", "true"))); - assertTrue(BackendControlConnector.quickSetupCapabilityAccepted("vote-party", false, true, false, + 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"), diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 8893401ce..4ad8f6eb6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -1,6 +1,7 @@ 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; @@ -92,6 +93,147 @@ void completedOperationCanBeFoundBeforeReplayingTheReceiveHook() throws Exceptio 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 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", 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 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(); @@ -159,14 +301,17 @@ void durableReplayCheckpointAcknowledgesAnAdditionBeforeRetentionStarts() throws 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, createIndex); + 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(2)).prepareStatement(statements.capture()); - assertTrue(statements.getAllValues().get(1).contains("(`state`, `created_at`)")); + 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 { @@ -181,6 +326,27 @@ private static ResultSet completedRow(String uuid, String pointsColumn, int amou 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); From 9d7a6c512383c33e08b0405c7444f225188462d3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:10:29 -0600 Subject: [PATCH 54/74] Make shared point mutations durably confirmable --- .../votingplugin/commands/CommandLoader.java | 15 ++++-- .../control/BackendConfigurationService.java | 4 +- .../user/SharedMysqlPointMutator.java | 40 ++++++++++++++- .../user/SharedPointAdditionJournal.java | 26 +++++++++- .../votingplugin/user/VotingPluginUser.java | 51 ++++++++++++++++--- .../user/SharedPointAdditionJournalTest.java | 37 ++++++++++++++ .../VotingPluginUserPointSchedulingTest.java | 36 ++++++++++++- 7 files changed, 191 insertions(+), 18 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 0254509eb..58e203b55 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -496,7 +496,8 @@ public void executeAll(CommandSender sender, String[] args) { new java.util.concurrent.atomic.AtomicInteger(users.size()); java.util.concurrent.atomic.AtomicInteger updated = new java.util.concurrent.atomic.AtomicInteger(); - VotingPluginUser.addPointsStorageAware(plugin, users, num, (user, success) -> { + String batchOperationId = "admin-bulk-points/" + UUID.randomUUID(); + VotingPluginUser.addPointsStorageAware(plugin, users, num, batchOperationId, (user, success) -> { try { if (success) { updated.incrementAndGet(); @@ -509,7 +510,8 @@ public void executeAll(CommandSender sender, String[] args) { if (remaining.decrementAndGet() == 0) { runForCommandSender(sender, () -> { sender.sendMessage(MessageAPI.colorize("&cGave all players " + args[3] - + " points to " + updated.get() + "/" + users.size() + " players")); + + " points to " + updated.get() + "/" + users.size() + + " players. Any failure may be indeterminate; do not rerun without reconciliation.")); plugin.getPlaceholders().onUpdate(); }); } @@ -584,7 +586,8 @@ public void executeAll(CommandSender sender, String[] args) { java.util.concurrent.atomic.AtomicInteger remaining = new java.util.concurrent.atomic.AtomicInteger(users.size()); java.util.concurrent.atomic.AtomicInteger removed = new java.util.concurrent.atomic.AtomicInteger(); - VotingPluginUser.removePointsStorageAware(plugin, users, num, (user, success) -> { + String batchOperationId = "admin-bulk-remove/" + UUID.randomUUID(); + VotingPluginUser.removePointsStorageAware(plugin, users, num, batchOperationId, (user, success) -> { try { if (success) { removed.incrementAndGet(); @@ -596,7 +599,8 @@ public void executeAll(CommandSender sender, String[] args) { if (remaining.decrementAndGet() == 0) { runForCommandSender(sender, () -> { sender.sendMessage(MessageAPI.colorize("&cRemoved " + args[3] + " points from " - + removed.get() + "/" + userIds.size() + " players")); + + removed.get() + "/" + userIds.size() + + " players. Any failure may be indeterminate; do not rerun without reconciliation.")); plugin.getPlaceholders().onUpdate(); }); } @@ -611,7 +615,8 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { user.removePoints(Integer.parseInt(args[3]), removed -> { if (!removed) { runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( - "&cUnable to remove " + args[3] + " points from " + args[1]))); + "&cUnable to confirm removing " + args[3] + " points from " + args[1] + + "; do not retry without reconciliation"))); return; } if (user.isOnline()) user.sendMessage( diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java index bf177407c..cad7a4b7b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/control/BackendConfigurationService.java @@ -230,7 +230,7 @@ public QuickState readQuickSetup(String preset, Map options) thr rejectUnknownOptions(options, "vote-site".equals(preset) ? Set.of("name") : "vote-party".equals(preset) ? Set.of("enabled") : Set.of()); if ("vote-site".equals(preset)) option(options, "name", "[A-Za-z0-9_-]{1,64}"); - if ("vote-party".equals(preset) && options.containsKey("enabled")) { + if ("vote-party".equals(preset) && options != null && options.containsKey("enabled")) { booleanOption(options, "enabled"); } return retryRead(() -> readQuickSetupOnce(preset, options)); @@ -503,7 +503,7 @@ private QuickProposal quickProposal(String preset, Map options, if ("vote-party".equals(preset)) { // v1 does not carry Enabled, so it must leave the installed value untouched. // v2 supplies the field so its actual state can round-trip explicitly. - if (options.containsKey("enabled")) { + if (options != null && options.containsKey("enabled")) { yaml.set("VoteParty.Enabled", booleanOption(options, "enabled")); } yaml.set("VoteParty.VotesRequired", boundedInteger(option(options, "votesRequired", "[0-9]{1,6}"), 1, 100000)); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index c311cef05..1dbfc8500 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -159,7 +159,24 @@ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { return new AddResult(true, result.total()); } catch (SQLException failure) { logFailure(failure); - return new AddResult(false, 0); + return new AddResult(MutationOutcome.INDETERMINATE, 0); + } finally { + discardPointsCache(user); + } + } + + /** Durable, confirmable conditional debit for administrative and purchase retries. */ + AddResult removeCommitted(VotingPluginUser user, int amount, String operationId) { + if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0); + drainCache(user); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .subtract(operationId, user.getUUID(), user.getPointsPath(), amount, System.currentTimeMillis()); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException + ? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0); } finally { discardPointsCache(user); } @@ -230,6 +247,18 @@ CompletionStage acknowledgePointAddition(String operationId) { return completion; } + void acknowledgePointAdditionNow(String operationId) { + if (!applies() || operationId == null || operationId.isEmpty()) return; + try { + SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, + System.currentTimeMillis()); + } catch (SQLException failure) { + // The mutation is already confirmed. Keep the COMPLETED row as a safe + // replay record when its retirement checkpoint cannot be persisted. + logFailure(failure); + } + } + void set(VotingPluginUser user, int value, boolean async) { run(() -> setAbsolute(user, value), async); } @@ -854,7 +883,14 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { return new AddResult(true, user.getPoints()); } - record AddResult(boolean success, int total) {} + enum MutationOutcome { CONFIRMED, REJECTED, INDETERMINATE } + + record AddResult(MutationOutcome outcome, int total) { + AddResult(boolean success, int total) { + this(success ? MutationOutcome.CONFIRMED : MutationOutcome.INDETERMINATE, total); + } + boolean success() { return outcome == MutationOutcome.CONFIRMED; } + } private boolean setAbsolute(VotingPluginUser user, int value) { drainCache(user); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 158c69337..636c8108e 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -76,6 +76,19 @@ static SharedPointAdditionJournal forTable(MySQL table) throws SQLException { /** Applies the operation exactly once and returns the resulting durable total. */ AdditionResult add(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException { + return mutate(operationId, uuid, pointsColumn, amount, now, false); + } + + /** Applies a durable conditional debit. A retry confirms the journal row and + * never debits the player twice; a row with insufficient points is a definite + * rejection and is not recorded as a successful mutation. */ + AdditionResult subtract(String operationId, String uuid, String pointsColumn, int amount, long now) throws SQLException { + if (amount < 0) throw new SQLException("Invalid shared point debit"); + return mutate(operationId, uuid, pointsColumn, -amount, now, true); + } + + private AdditionResult mutate(String operationId, String uuid, String pointsColumn, int amount, long now, + boolean requireNonnegative) throws SQLException { if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); AdditionRow existing = find(operationId); if (existing != null) return existingResult(operationId, existing, uuid, pointsColumn, amount); @@ -85,7 +98,8 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo + qi("state") + ", " + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; String points = qi(pointsColumn); String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points - + " + ? WHERE " + qi("uuid") + uuidCast(); + + " + ? WHERE " + qi("uuid") + uuidCast() + + (requireNonnegative ? " AND " + points + " >= ?" : ""); String read = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast(); String complete = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("total_points") + " = ? WHERE " + qi("operation_id") + " = ?"; @@ -107,8 +121,10 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo updateStatement.setInt(1, amount); updateStatement.setString(2, uuid); + if (requireNonnegative) updateStatement.setInt(3, -amount); if (updateStatement.executeUpdate() != 1) { rollback(connection); + if (requireNonnegative) throw new DebitRejectedException(); throw new SQLException("Shared point user row missing"); } readStatement.setString(1, uuid); @@ -141,6 +157,14 @@ AdditionResult add(String operationId, String uuid, String pointsColumn, int amo } } + static final class DebitRejectedException extends SQLException { + private static final long serialVersionUID = 1L; + + DebitRejectedException() { + super("Shared point debit rejected: insufficient points or missing user"); + } + } + /** * Claims an idempotent reward operation before its arbitrary Bukkit receive * hook runs. The claim is shared by all backend JVMs, unlike the caller's diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index cca17d68d..5268d1726 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -469,6 +469,11 @@ public void addPointsStorageAware(int value, Consumer completion) { */ public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, BiConsumer completion) { + addPointsStorageAware(plugin, users, value, "admin-bulk-points/" + UUID.randomUUID(), completion); + } + + public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, + String batchOperationId, BiConsumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (!sharedPoints.applies()) { for (VotingPluginUser user : users) { @@ -485,7 +490,15 @@ public static void addPointsStorageAware(VotingPluginMain plugin, List { Integer amount = eventAmounts.get(user); - return amount != null && mutator.addCommitted(user, amount).success(); + if (amount == null) return false; + // Every member of a bulk operation gets its own durable id. A + // connection loss after commit must be confirmable per player; + // sharing one id would make a retry unable to distinguish which + // rows were already credited. + String operationId = bulkPointOperationId("admin-bulk-points/", batchOperationId, user.getUUID()); + boolean success = mutator.addCommitted(user, amount, operationId).success(); + if (success) mutator.acknowledgePointAdditionNow(operationId); + return success; }, (user, done) -> done.accept(false)); } @@ -546,11 +559,27 @@ public void setPointsStorageAware(int value, Consumer completion) { */ public static void removePointsStorageAware(VotingPluginMain plugin, List users, int value, BiConsumer completion) { + removePointsStorageAware(plugin, users, value, "admin-bulk-remove/" + UUID.randomUUID(), completion); + } + + public static void removePointsStorageAware(VotingPluginMain plugin, List users, int value, + String batchOperationId, BiConsumer completion) { bulkSharedMysqlMutation(plugin, users, completion, - (mutator, user) -> mutator.remove(user, value), + (mutator, user) -> { + String operationId = bulkPointOperationId("admin-bulk-remove/", batchOperationId, user.getUUID()); + boolean success = mutator.removeCommitted(user, value, operationId).success(); + if (success) mutator.acknowledgePointAdditionNow(operationId); + return success; + }, (user, done) -> user.removePoints(value, done)); } + static String bulkPointOperationId(String prefix, String batchOperationId, String userId) { + UUID digest = UUID.nameUUIDFromBytes((batchOperationId + "/" + userId) + .getBytes(java.nio.charset.StandardCharsets.UTF_8)); + return prefix + digest; + } + @FunctionalInterface private interface SharedPointMutation { boolean apply(SharedMysqlPointMutator mutator, VotingPluginUser user); @@ -1773,20 +1802,30 @@ public boolean removePoints(int points, boolean async) { /** Removes points without performing shared-database I/O on the caller thread. */ public void removePoints(int points, Consumer completion) { + removePointsOutcome(points, outcome -> completion.accept(outcome == SharedMysqlPointMutator.MutationOutcome.CONFIRMED)); + } + + void removePointsOutcome(int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (!sharedPoints.applies()) { - completion.accept(removePoints(points)); + completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED + : SharedMysqlPointMutator.MutationOutcome.REJECTED); return; } Player player = getPlayer(); try { + String operationId = "remove-points/" + UUID.randomUUID(); plugin.getTimer().execute(() -> { - boolean removed = sharedPoints.remove(this, points); - BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(removed)); + SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommitted(this, points, operationId); + if (result.outcome() == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) { + sharedPoints.acknowledgePointAdditionNow(operationId); + } + BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(result.outcome())); }); } catch (RuntimeException rejected) { plugin.debug(rejected); - BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(false)); + BukkitCompletionScheduler.run(plugin, player, + () -> completion.accept(SharedMysqlPointMutator.MutationOutcome.INDETERMINATE)); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 4ad8f6eb6..24a1aead1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -22,6 +22,43 @@ 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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index c76c4fec6..eee9b9166 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -54,6 +54,16 @@ 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 sharedBulkPointMutationsUseOnePersistenceSubmission() throws Exception { PointFixture fixture = pointFixture(); @@ -674,7 +684,28 @@ void sharedAsyncRemoveKeepsJdbcOffTheCallerThread() throws Exception { @Test void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throws Exception { PointFixture fixture = pointFixture(); - when(fixture.statement.executeUpdate()).thenReturn(1); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(lookup.executeQuery()).thenReturn(missing); + PreparedStatement insert = mock(PreparedStatement.class); + PreparedStatement debit = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement complete = mock(PreparedStatement.class); + when(debit.executeUpdate()).thenReturn(1); + ResultSet total = mock(ResultSet.class); + when(total.next()).thenReturn(true); + when(total.getInt(1)).thenReturn(10); + when(read.executeQuery()).thenReturn(total); + when(complete.executeUpdate()).thenReturn(1); + when(fixture.connection.prepareStatement(anyString())).thenAnswer(invocation -> { + String sql = invocation.getArgument(0, String.class); + if (sql.startsWith("SELECT `player_uuid`")) return lookup; + if (sql.startsWith("INSERT INTO `VotingPlugin_Users_PointAdditions`")) return insert; + if (sql.startsWith("UPDATE `VotingPlugin_Users` SET")) return debit; + if (sql.startsWith("SELECT `Points` FROM `VotingPlugin_Users`")) return read; + if (sql.startsWith("UPDATE `VotingPlugin_Users_PointAdditions` SET `state`")) return complete; + return fixture.statement; + }); AtomicReference result = new AtomicReference<>(); fixture.user.removePoints(10, result::set); @@ -689,7 +720,8 @@ void sharedRemoveConsumerRunsJdbcOnPersistenceExecutorAndReportsOnEntity() throw assertTrue(result.get() == null); entityWork.getValue().run(); assertTrue(result.get()); - verify(fixture.sql.getConnectionManager()).getConnection(); + verify(complete, org.mockito.Mockito.times(2)).executeUpdate(); + verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.atLeastOnce()).getConnection(); } @Test From 3304911e249aff5a91aea965ec7de422bf3a2450 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:33:04 -0600 Subject: [PATCH 55/74] Use a stable vote shop reset zone --- .../service/VoteShopPurchaseService.java | 24 ++++++++---- .../service/VoteShopPurchaseServiceTest.java | 38 ++++++++++++++++++- 2 files changed, 53 insertions(+), 9 deletions(-) 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 0f0d86cb3..7f3caa83f 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 @@ -5,6 +5,7 @@ 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; @@ -743,24 +744,33 @@ private static LimitGeneration limitGeneration(VotingPluginMain plugin, String i boolean daily = plugin.getShopFile().getVoteShopResetDaily(identifier); boolean weekly = plugin.getShopFile().getVoteShopResetWeekly(identifier); boolean monthly = plugin.getShopFile().getVoteShopResetMonthly(identifier); - return limitGeneration(plugin.getTimeChecker().getTime(), nowMillis, daily, weekly, monthly, - plugin.getOptions().getTimeWeekOffSet(), configuredTimeZone(plugin), - plugin.getOptions().getTimeHourOffSet()); + 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) { - String configured = plugin.getOptions().getTimeZone(); - if (configured == null || configured.isEmpty()) return ZoneId.systemDefault(); + 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.systemDefault(); + 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.systemDefault(), 0); + return limitGeneration(current, nowMillis, daily, weekly, monthly, weekOffset, ZoneId.of("UTC"), 0); } private static LimitGeneration limitGeneration(LocalDateTime current, long nowMillis, boolean daily, boolean weekly, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index f0ceb5232..2cc653827 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -36,6 +36,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.HashMap; import java.util.Locale; +import java.util.TimeZone; import java.util.UUID; import org.bukkit.configuration.file.FileConfiguration; @@ -60,6 +61,39 @@ import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; class VoteShopPurchaseServiceTest { + @Test + void missingOrInvalidNetworkTimeZoneUsesUtc() { + assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone(null)); + assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone(" ")); + assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone("not/a-zone")); + assertEquals(ZoneId.of("America/Regina"), + VoteShopPurchaseService.networkTimeZone("America/Regina")); + } + + @Test + void utcLimitGenerationDoesNotDependOnJvmDefaultTimeZone() { + TimeZone previous = TimeZone.getDefault(); + LocalDateTime current = LocalDateTime.of(2026, 9, 8, 23, 30); + long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); + try { + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Honolulu")); + LocalDateTime honoluluCurrent = VoteShopPurchaseService.networkCurrentTime( + now, VoteShopPurchaseService.networkTimeZone(""), 0); + VoteShopPurchaseService.LimitGeneration honolulu = VoteShopPurchaseService.limitGeneration( + honoluluCurrent, now, true, true, true, 0); + TimeZone.setDefault(TimeZone.getTimeZone("Pacific/Kiritimati")); + LocalDateTime kiritimatiCurrent = VoteShopPurchaseService.networkCurrentTime( + now, VoteShopPurchaseService.networkTimeZone("invalid"), 0); + VoteShopPurchaseService.LimitGeneration kiritimati = VoteShopPurchaseService.limitGeneration( + kiritimatiCurrent, now, true, true, true, 0); + assertEquals(current, honoluluCurrent); + assertEquals(current, kiritimatiCurrent); + assertEquals(honolulu, kiritimati); + } finally { + TimeZone.setDefault(previous); + } + } + @Test void unconfirmedRewardClaimIsCompensatedBeforeTheRewardCanStart() { assertTrue(VoteShopPurchaseService.requiresCompensation( @@ -83,7 +117,7 @@ void retainsSynchronousPurchaseDescriptorsForBinaryCompatibility() throws Except @Test void limitGenerationUsesTheEarliestConfiguredResetBoundary() { LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); - long now = current.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); VoteShopPurchaseService.LimitGeneration generation = VoteShopPurchaseService.limitGeneration( current, now, true, true, true, 0); @@ -112,7 +146,7 @@ void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { void weeklyGenerationDoesNotDependOnTheJvmDefaultLocale() { Locale previous = Locale.getDefault(); LocalDateTime current = LocalDateTime.of(2027, 1, 3, 12, 0); - long now = current.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); try { Locale.setDefault(Locale.US); String usGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); From 3ac1341b0cec7ce2fdc9e101742022f10cab53c3 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sun, 13 Sep 2026 19:58:39 -0600 Subject: [PATCH 56/74] Recover point journals across mode changes --- .../events/PlayerReceivePointsEvent.java | 21 ++++++++++++++----- .../user/SharedMysqlPointMutator.java | 9 ++++++-- .../votingplugin/user/UserManager.java | 3 ++- .../votingplugin/user/VotingPluginUser.java | 4 ++-- .../service/VoteShopPurchaseService.java | 7 ++++++- .../user/SharedMysqlPointMutatorTest.java | 18 +++++++++++----- .../VotingPluginUserPointSchedulingTest.java | 1 + .../service/VoteShopPurchaseServiceTest.java | 9 ++++++++ 8 files changed, 56 insertions(+), 16 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java index 65d6bde5f..f33486556 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/PlayerReceivePointsEvent.java @@ -44,11 +44,22 @@ public static HandlerList getHandlerList() { * @param user the voting plugin user * @param points the points received */ - public PlayerReceivePointsEvent(VotingPluginUser user, int points) { - super(true); - this.player = user; - this.points = points; - } + public PlayerReceivePointsEvent(VotingPluginUser user, int points) { + this(user, points, true); + } + + /** + * Constructs an event with an explicit Bukkit dispatch context. + * + * @param user the voting plugin user + * @param points the points received + * @param async whether the event is being dispatched off the Bukkit/entity lane + */ + public PlayerReceivePointsEvent(VotingPluginUser user, int points, boolean async) { + super(async); + this.player = user; + this.points = points; + } @Override public HandlerList getHandlers() { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 1dbfc8500..483bceadc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -48,6 +48,11 @@ static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { && !plugin.getBungeeSettings().isPerServerPoints(); } + /** Existing durable rows must be recovered even after shared points are disabled. */ + static boolean canRecoverSharedMysqlPointJournals(VotingPluginMain plugin) { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()); + } + /** * Recovers a bounded batch immediately and periodically. The executor belongs * to the plugin lifecycle, so no independent task survives shutdown. @@ -70,7 +75,7 @@ private static void recoverSharedPointJournalsSafely(VotingPluginMain plugin) { private static void recoverSharedPointJournals(VotingPluginMain plugin) { recoverTransfers(plugin); - if (!usesSharedMysqlPoints(plugin)) return; + if (!canRecoverSharedMysqlPointJournals(plugin)) return; try { SharedPointAdditionJournal.forTable(plugin.getMysql()).cleanupAcknowledged(System.currentTimeMillis()); } catch (SQLException failure) { @@ -81,7 +86,7 @@ private static void recoverSharedPointJournals(VotingPluginMain plugin) { } private static void recoverTransfers(VotingPluginMain plugin) { - if (!usesSharedMysqlPoints(plugin)) return; + if (!canRecoverSharedMysqlPointJournals(plugin)) return; try { recoverTransfers(plugin, SharedPointTransferJournal.forTable(plugin.getMysql())); } catch (SQLException failure) { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java index 290d6eb55..8c6b2c156 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/UserManager.java @@ -35,7 +35,8 @@ public UserManager(VotingPluginMain plugin) { /** Starts the durable shared-point transfer recovery exactly once per plugin lifecycle. */ public synchronized void startSharedPointTransferRecovery() { - if (sharedPointTransferRecoveryScheduled || !SharedMysqlPointMutator.usesSharedMysqlPoints(plugin)) return; + if (sharedPointTransferRecoveryScheduled + || !SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) return; try { SharedMysqlPointMutator.scheduleTransferRecovery(plugin); sharedPointTransferRecoveryScheduled = true; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 5268d1726..276f35cdd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -422,7 +422,7 @@ private record ReplayPointKey(VotingPluginMain plugin, String operationId, Strin private void submitSharedPointAdditionAfterReplayLookup(SharedMysqlPointMutator sharedPoints, int value, String operationId, String uuid, String pointsPath, String claimOwner, CompletableFuture completion) { - PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value, false); Bukkit.getPluginManager().callEvent(event); try { plugin.getTimer().execute(() -> { @@ -1857,7 +1857,7 @@ public void transferPointsWithResult(VotingPluginUser target, int points, Consum SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (sharedPoints.applies()) { sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { - PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points); + PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false); Bukkit.getPluginManager().callEvent(receiveEvent); return receiveEvent.isCancelled() ? null : receiveEvent.getPoints(); }, completion); 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 7f3caa83f..155ca8110 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 @@ -483,6 +483,11 @@ private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { && !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. @@ -523,7 +528,7 @@ static void withSharedMysqlCacheDumpFence(Runnable action) { /** Runs bounded stale-purchase recovery from the plugin lifecycle executor. */ public static void recoverSharedMysqlPurchases(VotingPluginMain plugin) { - if (!usesSharedMysqlPoints(plugin)) return; + if (!canRecoverSharedMysqlPurchases(plugin)) return; try { recoverSharedMysqlPurchases(plugin, SharedMysqlPurchaseJournal.forTable(plugin.getMysql())); } catch (SQLException failure) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 219e2998c..596b07442 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -205,17 +205,15 @@ void userManagerSchedulesOneBoundedSharedTransferRecoveryPerLifecycle() { } @Test - void userManagerSchedulesRecoveryOnceWhenReloadEnablesSharedPoints() { + 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, false); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); when(plugin.getTimer()).thenReturn(persistence); UserManager manager = new UserManager(plugin); - manager.startSharedPointTransferRecovery(); // Startup with per-server points. - verifyNoInteractions(persistence); - manager.startSharedPointTransferRecovery(); // Reload switches to shared points. + 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)); @@ -224,6 +222,16 @@ void userManagerSchedulesRecoveryOnceWhenReloadEnablesSharedPoints() { 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 rejectedRecoverySchedulingCanRetryLater() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index eee9b9166..ab70b2303 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -734,6 +734,7 @@ void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() thro bukkit.when(Bukkit::getPluginManager).thenReturn(pluginManager); doAnswer(invocation -> { PlayerReceivePointsEvent event = invocation.getArgument(0); + assertFalse(event.isAsynchronous()); event.setPoints(4); return null; }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 2cc653827..eaac1a3b6 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -61,6 +61,15 @@ import com.bencodez.votingplugin.voteshop.shop.VoteShopItem; class VoteShopPurchaseServiceTest { + @Test + void purchaseRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + + assertTrue(VoteShopPurchaseService.canRecoverSharedMysqlPurchases(plugin)); + } + @Test void missingOrInvalidNetworkTimeZoneUsesUtc() { assertEquals(ZoneId.of("UTC"), VoteShopPurchaseService.networkTimeZone(null)); From f192886726124ab9e3c2f2709395c74967f0d4ce Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:07:28 -0600 Subject: [PATCH 57/74] Harden shared point and shop recovery --- .../topvoter/TopVoterHandler.java | 9 ++ .../user/SharedMysqlPointMutator.java | 25 +++- .../user/SharedPointAdditionJournal.java | 60 ++++++++ .../votingplugin/user/VotingPluginUser.java | 54 ++++++- .../service/SharedMysqlPurchaseJournal.java | 4 +- .../service/VoteShopPurchaseService.java | 138 +++++++++++++----- .../user/SharedMysqlPointMutatorTest.java | 52 +++++++ .../user/SharedPointAdditionJournalTest.java | 47 ++++++ .../VotingPluginUserPointSchedulingTest.java | 104 +++++++++++++ .../VotingPluginUserVoteShopLimitTest.java | 18 +++ .../util/BukkitCompletionSchedulerTest.java | 18 +++ .../service/VoteShopPurchaseServiceTest.java | 128 +++++++++++++++- 12 files changed, 604 insertions(+), 53 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java index b60289d9b..36dfdc77d 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/topvoter/TopVoterHandler.java @@ -540,6 +540,15 @@ private void resetVoteShopLimit(String shopIdent, String resetGeneration) { else VoteShopPurchaseService.resetSharedMysqlLimit(plugin, limitColumn, resetGeneration); return; } + if (UserStorage.MYSQL.equals(plugin.getStorageType())) { + // The limit column is still shared with backends that have not switched to + // per-server points. Its wipe and epoch advance must therefore use the + // journal's one transaction; doing the UserManager wipe after advancing the + // epoch can erase a new-epoch reservation. + VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence(plugin, limitColumn, + resetGeneration == null ? UUID.randomUUID().toString() : resetGeneration); + return; + } plugin.getUserManager().removeAllKeyValues(limitColumn, DataType.INTEGER); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 483bceadc..39803fa73 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -216,6 +216,12 @@ void markPointAdditionIndeterminate(String operationId, String uuid, String poin requestedAmount, owner); } + void releaseUnstartedPointAdditionHook(String operationId, String uuid, String pointsColumn, int requestedAmount, + String owner) throws SQLException { + SharedPointAdditionJournal.forTable(plugin.getMysql()).releaseUnstartedHook(operationId, uuid, pointsColumn, + requestedAmount, owner); + } + AddResult settleClaimedPointAddition(VotingPluginUser user, String operationId, String uuid, String pointsColumn, int requestedAmount, String owner, Integer adjustedAmount) { drainCache(user); @@ -523,8 +529,7 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser scheduleRejectedTransferCompensation(source, completion, journal, transferId, sourcePoints, debitAmount); }; try { - CompletableFuture approval = plugin.getBukkitScheduler().getFoliaLib().getImpl() - .runAtEntityWithFallback(approvalPlayer, ignored -> { + runTransferApprovalEntityTask(approvalPlayer, () -> { if (!approvalState.compareAndSet(0, 1)) return; Integer approvedAmount; try { @@ -552,15 +557,25 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser approvalState.set(2); } }, rejectBeforeStart); - approval.whenComplete((result, failure) -> { - if (failure != null || result != EntityTaskResult.SUCCESS) rejectBeforeStart.run(); - }); } catch (RuntimeException schedulingFailure) { plugin.debug(schedulingFailure); rejectBeforeStart.run(); } } + /** Keeps Folia's entity-retirement result while safely supporting legacy Bukkit scheduling. */ + private void runTransferApprovalEntityTask(org.bukkit.entity.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 scheduleRejectedTransferCompensation(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { Runnable compensation = () -> compensateRejectedTransfer(source, completion, journal, transferId, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 636c8108e..520cc7342 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -404,6 +404,66 @@ void markIndeterminate(String operationId, String uuid, String pointsColumn, int } } + /** + * Releases a hook claim only when the scheduler proved that its callback never + * began. Unlike {@link #markIndeterminate(String, String, String, int, String)}, + * this makes an idempotent retry safe because no listener was invoked. + */ + void releaseUnstartedHook(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner) + throws SQLException { + if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + + qi("operation_id") + " = ? FOR UPDATE"; + String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? AND " + + qi("state") + " = ? AND " + qi("hook_owner") + " = ?"; + try (Connection connection = connection()) { + connection.setAutoCommit(false); + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + return; + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + AdditionRow row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), + result.getString(4), total, requested, result.getString(7), result.getLong(8)); + if (!row.matchesTarget(uuid, pointsColumn) + || row.requestedAmount != null && row.requestedAmount.intValue() != requestedAmount) { + rollback(connection); + throw new SQLException("Mismatched shared point addition operation"); + } + if (!HOOK_STARTED.equals(row.state) || !owner.equals(row.hookOwner)) { + rollback(connection); + return; + } + } + } + try (PreparedStatement deleteStatement = connection.prepareStatement(delete)) { + deleteStatement.setString(1, operationId); + deleteStatement.setString(2, HOOK_STARTED); + deleteStatement.setString(3, owner); + if (deleteStatement.executeUpdate() != 1) { + rollback(connection); + return; + } + } + try { + connection.commit(); + } catch (SQLException ambiguousCommit) { + // A shutdown can interrupt the acknowledgement after the delete reached + // MySQL. Confirm through a fresh connection before classifying a + // scheduler-proven unstarted hook as unresolved. + closeQuietly(connection); + if (find(operationId) == null) return; + throw ambiguousCommit; + } + } + } + /** * Returns a previously committed addition before a retry invokes its Bukkit * receive hook. The amount deliberately remains part of {@link #add}: a diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 276f35cdd..40ccdb948 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -367,8 +367,8 @@ private CompletionStage addSharedPointsWithReplayLookup(SharedMysqlPoin reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, claimOwner, completion, failure); } - }, () -> reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, - claimOwner, completion, null)); + }, () -> releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, pointsPath, value, + claimOwner, completion)); } catch (Throwable failure) { completion.completeExceptionally(failure); } @@ -417,6 +417,46 @@ private void reportIndeterminateSharedPointAddition(SharedMysqlPointMutator shar } } + /** + * A rejected scheduler never invoked the receive hook. Queue release of that + * exact durable claim on a database-safe worker. Scheduler completions can run + * on a Folia entity lane, so this callback must never open a JDBC connection + * directly. If shutdown rejects every database-safe worker, HOOK_STARTED is + * deliberately retained for reconciliation instead of making a retry unsafe. + * Started hooks still use the indeterminate path above. + */ + private void releaseUnstartedSharedPointAddition(SharedMysqlPointMutator sharedPoints, String operationId, + String uuid, String pointsPath, int requestedAmount, String claimOwner, CompletableFuture completion) { + Runnable release = () -> { + try { + sharedPoints.releaseUnstartedPointAdditionHook(operationId, uuid, pointsPath, requestedAmount, claimOwner); + completion.completeExceptionally( + new IllegalStateException("Shared MySQL point addition was not started; retry is safe")); + } catch (SQLException failure) { + plugin.getLogger().severe("Unable to release unstarted shared MySQL point addition " + operationId + + "; retaining its claim for manual reconciliation"); + plugin.debug(failure); + completion.completeExceptionally( + new IllegalStateException("Unable to release unstarted shared MySQL point addition", failure)); + } + }; + try { + plugin.getTimer().execute(release); + } catch (RuntimeException persistenceRejected) { + plugin.debug(persistenceRejected); + try { + plugin.getBukkitScheduler().runTaskAsynchronously(plugin, release); + } catch (RuntimeException asyncRejected) { + plugin.debug(asyncRejected); + plugin.getLogger().severe("Unable to schedule release of unstarted shared MySQL point addition " + + operationId + "; retaining its claim for manual reconciliation"); + completion.completeExceptionally(new IllegalStateException( + "Unable to schedule release of unstarted shared MySQL point addition; retry requires reconciliation", + asyncRejected)); + } + } + } + private record ReplayPointKey(VotingPluginMain plugin, String operationId, String uuid, String pointsPath, int requestedAmount) { } @@ -2248,9 +2288,13 @@ public void setVotePartyVotes(int value) { */ public void setVoteShopIdentifierLimit(String identifier, int value) { String path = "VoteShopLimit" + identifier; - // Shared-MySQL purchase/reset transactions own these columns. Never leave an - // absolute queued cache write that another backend's reset cannot fence. - getData().setInt(path, value, !usesSharedMysqlPoints()); + // VoteShopLimit columns are not server-suffixed, even when points are. On + // MySQL, always make their writes direct: an asynchronous absolute cache + // write can otherwise land after a journalled reset (or a PerServerPoints + // mode change) and restore a stale period's limit. Non-MySQL storage keeps + // its established queued-write behavior. + boolean queue = plugin == null || !UserStorage.MYSQL.equals(plugin.getStorageType()); + getData().setInt(path, value, queue); } private boolean usesSharedMysqlPoints() { 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 index e2c29d80d..28eef10db 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -197,8 +197,8 @@ void resetLimit(String limitColumn, String resetGeneration) throws SQLException 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"); + 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") + " = ?")) { 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 155ca8110..4f6c895ca 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 @@ -13,6 +13,7 @@ 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; @@ -22,7 +23,6 @@ import com.bencodez.advancedcore.api.messages.PlaceholderUtils; import com.bencodez.advancedcore.api.rewards.RewardOptions; -import com.bencodez.advancedcore.api.time.TimeCalculation; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -53,6 +53,14 @@ public class VoteShopPurchaseService { 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; @@ -85,7 +93,7 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u // 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 (usesSharedMysqlPoints()) return VoteShopPurchaseResult.SUCCESS; + if (usesMysqlPurchaseReservation(item)) return VoteShopPurchaseResult.SUCCESS; if (item.getLimit() > 0 && user.getVoteShopIdentifierLimit(item.getIdentifier()) >= item.getLimit()) { return VoteShopPurchaseResult.LIMIT_REACHED; } @@ -97,7 +105,7 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u /** Refreshes dynamic GUI validation state only when that refresh cannot block on shared MySQL. */ public void refreshUserForPurchaseValidation(VotingPluginUser user, boolean requested) { - if (requested && !usesSharedMysqlPoints()) user.cache(); + if (requested && !usesMysqlPurchaseReservation(null)) user.cache(); } private VoteShopPurchaseResult validateStaticPurchase(Player player, VoteShopItem item) { @@ -158,7 +166,7 @@ private VoteShopPurchaseResult purchaseLocal(Player player, VotingPluginUser use */ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, Consumer completion) { - if (!usesSharedMysqlPoints()) { + if (!usesMysqlPurchaseReservation(item)) { completion.accept(purchaseLocal(player, user, item)); return; } @@ -208,7 +216,7 @@ public void purchase(Player player, VotingPluginUser user, VoteShopItem item, */ @Deprecated public VoteShopPurchaseResult purchase(Player player, VotingPluginUser user, VoteShopItem item) { - if (!usesSharedMysqlPoints()) return purchaseLocal(player, user, 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 -> { }); @@ -229,31 +237,34 @@ private void completeSharedMysqlPurchase(Player player, VotingPluginUser user, V * 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. + * reward callback. BukkitCompletionScheduler retains that entity/global + * fallback behavior on Folia and safely uses Bukkit scheduling when Folia + * support is absent. */ - CompletableFuture gate = plugin.getBukkitScheduler().getFoliaLib().getImpl() - .runAtEntityWithFallback(player, ignored -> { + runPurchaseEntityTask(player, () -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; - 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); + 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; } - 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); } - state.compareAndSet(COMPLETION_RUNNING, COMPLETION_FINISHED); - scheduleClaimedReward(player, user, item, placeholders, shopData, completion, debit); - }); - }, compensateBeforeClaim); - gate.whenComplete((result, failure) -> { - if (failure != null || result != EntityTaskResult.SUCCESS) { - compensateBeforeClaim.run(); + plugin.debug(claimSchedulingFailure); } - }); + }, compensateBeforeClaim); } catch (RuntimeException schedulingFailure) { compensateBeforeClaim.run(); plugin.debug(schedulingFailure); @@ -276,8 +287,7 @@ void scheduleClaimedReward(Player player, VotingPluginUser user, VoteShopItem it } }; try { - CompletableFuture reward = plugin.getBukkitScheduler().getFoliaLib().getImpl() - .runAtEntityWithFallback(player, ignored -> { + runPurchaseEntityTask(player, () -> { if (!state.compareAndSet(COMPLETION_PENDING, COMPLETION_RUNNING)) return; try { completePurchase(player, user, item, placeholders, shopData); @@ -312,9 +322,6 @@ void scheduleClaimedReward(Player player, VotingPluginUser user, VoteShopItem it state.set(COMPLETION_FINISHED); } }, rejectBeforeStart); - reward.whenComplete((result, failure) -> { - if (failure != null || result != EntityTaskResult.SUCCESS) rejectBeforeStart.run(); - }); } catch (RuntimeException schedulingFailure) { rejectBeforeStart.run(); plugin.debug(schedulingFailure); @@ -326,6 +333,24 @@ private void logClaimedRewardSchedulingFailure(SharedPurchaseDebit debit) { + " 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 { @@ -478,6 +503,17 @@ 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(); @@ -518,6 +554,36 @@ public static void resetSharedMysqlLimit(VotingPluginMain plugin, String limitCo }); } + /** + * 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); } @@ -789,8 +855,8 @@ private static LimitGeneration limitGeneration(LocalDateTime current, long nowMi } if (weekly) { LocalDateTime weekBoundary = current.toLocalDate().plusDays(1).atStartOfDay(); - int week = TimeCalculation.weekNumber(current, weekOffset, Locale.ROOT); - while (TimeCalculation.weekNumber(weekBoundary, weekOffset, Locale.ROOT) == week) { + int week = networkWeekNumber(current, weekOffset); + while (networkWeekNumber(weekBoundary, weekOffset) == week) { weekBoundary = weekBoundary.plusDays(1); } if (next == null || weekBoundary.isBefore(next)) next = weekBoundary; @@ -810,8 +876,12 @@ private static LimitGeneration limitGeneration(LocalDateTime current, long nowMi static String weeklyGenerationId(LocalDateTime current, int weekOffset) { LocalDateTime weekTime = current.plusDays(weekOffset); - WeekFields fields = WeekFields.of(Locale.ROOT); - return "W:" + weekTime.get(fields.weekBasedYear()) + '-' + weekTime.get(fields.weekOfWeekBasedYear()); + 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, diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 596b07442..cc2fcb5ff 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; 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; @@ -17,6 +18,7 @@ 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; @@ -36,6 +38,56 @@ 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, 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, 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); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 24a1aead1..5d8568f9d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -175,6 +175,53 @@ void rejectedOrStoppedHookClaimIsDurablyMarkedForManualReconciliation() throws E 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 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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index ab70b2303..13ca44cc7 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -455,6 +455,110 @@ void durableSharedAsyncRetryCompletesBeforeFiringTheReceiveEvent() throws Except verifyNoInteractions(fixture.scheduler); } + @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 owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(statement).setString(eq(8), anyString()); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(claimed.getLong(8)).thenReturn(1L); + when(statement.executeQuery()).thenReturn(missing, claimed); + when(statement.executeUpdate()).thenReturn(1); + doThrow(new RejectedExecutionException("retired")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class)); + + CompletableFuture completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation") + .toCompletableFuture(); + ArgumentCaptor claimedWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(claimedWork.capture()); + claimedWork.getValue().run(); + + com.bencodez.simpleapi.sql.mysql.ConnectionManager manager = fixture.sql.getConnectionManager(); + org.mockito.Mockito.clearInvocations(fixture.persistence, manager, fixture.connection, statement); + entityCompletion.complete(EntityTaskResult.SCHEDULER_RETIRED); + + ArgumentCaptor releaseWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(releaseWork.capture()); + verifyNoInteractions(manager, fixture.connection, statement); + assertFalse(completion.isDone()); + releaseWork.getValue().run(); + + assertTrue(completion.isCompletedExceptionally()); + verify(fixture.scheduler, never()).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verify(fixture.connection).commit(); + verify(statement).setString(2, "HOOK_STARTED"); + } + + @Test + void retiredEntitySchedulerRetainsClaimWhenNoDatabaseSafeReleaseWorkerAcceptsWork() 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 owner = new AtomicReference<>(); + doAnswer(invocation -> { + owner.set(invocation.getArgument(1)); + return null; + }).when(statement).setString(eq(8), anyString()); + when(claimed.getString(7)).thenAnswer(invocation -> owner.get()); + when(claimed.getLong(8)).thenReturn(1L); + when(statement.executeQuery()).thenReturn(missing, claimed); + when(statement.executeUpdate()).thenReturn(1); + doThrow(new RejectedExecutionException("retired")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class), eq(fixture.player)); + doThrow(new RejectedExecutionException("stopping")).when(fixture.scheduler) + .runTask(eq(fixture.plugin), any(Runnable.class)); + + CompletableFuture completion = fixture.user.addPointsStorageAwareAsync(5, "reward-operation") + .toCompletableFuture(); + ArgumentCaptor claimedWork = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(claimedWork.capture()); + claimedWork.getValue().run(); + + com.bencodez.simpleapi.sql.mysql.ConnectionManager manager = fixture.sql.getConnectionManager(); + org.mockito.Mockito.clearInvocations(fixture.persistence, manager, fixture.connection, statement); + doThrow(new RejectedExecutionException("stopping")).when(fixture.persistence).execute(any(Runnable.class)); + doThrow(new RejectedExecutionException("disabling")).when(fixture.scheduler) + .runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + entityCompletion.complete(EntityTaskResult.SCHEDULER_RETIRED); + + assertTrue(completion.isCompletedExceptionally()); + verify(fixture.scheduler).runTaskAsynchronously(eq(fixture.plugin), any(Runnable.class)); + verifyNoInteractions(manager, fixture.connection, statement); + } + @Test void sharedAsyncAddReturnsThePredictedEventAdjustedTotalWithoutJdbcOnTheCaller() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java index 6859aa2f6..ec43050a0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserVoteShopLimitTest.java @@ -59,4 +59,22 @@ void sharedMysqlLimitsUseOnlyTemporaryDataWhenUserIsNotCached() { verify(data, never()).getInt("VoteShopLimitdaily", UserDataFetchMode.NO_CACHE); verify(data).setInt("VoteShopLimitdaily", 4, false); } + + @Test + void perServerMysqlLimitsAlsoUseDirectWritesBecauseTheirColumnIsShared() { + VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); + when(plugin.getStorageType()).thenReturn(UserStorage.MYSQL); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + UserData data = mock(UserData.class); + AdvancedCoreUser base = mock(AdvancedCoreUser.class); + when(base.getUserData()).thenReturn(data); + when(base.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(base.getPlayerName()).thenReturn("Player"); + + VotingPluginUser user = spy(new VotingPluginUser(plugin, base)); + doReturn(data).when(user).getData(); + user.setVoteShopIdentifierLimit("daily", 4); + + verify(data).setInt("VoteShopLimitdaily", 4, false); + } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java index 5865cb1be..175c91407 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/util/BukkitCompletionSchedulerTest.java @@ -4,11 +4,13 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.entity.Player; @@ -63,6 +65,22 @@ void missingSchedulerStatusRunsCompletionOnceOnGlobalFallback() { verify(fixture.scheduler).runTask(eq(fixture.plugin), any(Runnable.class)); } + @Test + void rejectedEveryFallbackCallsRejectedOnlyWhenTaskNeverBegan() { + Fixture fixture = fixture(); + 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)); + AtomicInteger completed = new AtomicInteger(); + AtomicInteger rejected = new AtomicInteger(); + + BukkitCompletionScheduler.run(fixture.plugin, fixture.player, completed::incrementAndGet, rejected::incrementAndGet); + + assertEquals(0, completed.get()); + assertEquals(1, rejected.get()); + } + private static Fixture fixture() { VotingPluginMain plugin = mock(VotingPluginMain.class); BukkitScheduler scheduler = mock(BukkitScheduler.class); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index eaac1a3b6..96c946e16 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -46,7 +46,6 @@ import org.mockito.InOrder; import com.bencodez.advancedcore.api.user.UserStorage; -import com.bencodez.advancedcore.api.time.TimeCalculation; import com.bencodez.advancedcore.api.user.UserData; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.usercache.UserDataCache; @@ -139,9 +138,9 @@ void limitGenerationUsesTheEarliestConfiguredResetBoundary() { @Test void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { LocalDateTime current = LocalDateTime.of(2026, 9, 8, 12, 0); - int currentWeek = TimeCalculation.weekNumber(current, 0, Locale.ROOT); + String currentGeneration = VoteShopPurchaseService.weeklyGenerationId(current, 0); LocalDateTime nextBoundary = current.toLocalDate().plusDays(1).atStartOfDay(); - while (TimeCalculation.weekNumber(nextBoundary, 0, Locale.ROOT) == currentWeek) { + while (VoteShopPurchaseService.weeklyGenerationId(nextBoundary, 0).equals(currentGeneration)) { nextBoundary = nextBoundary.plusDays(1); } @@ -152,7 +151,7 @@ void weeklyGenerationChangesAtEveryConfiguredWeekBoundary() { } @Test - void weeklyGenerationDoesNotDependOnTheJvmDefaultLocale() { + void weeklyGenerationAndBoundaryIgnoreJvmDefaultLocale() { Locale previous = Locale.getDefault(); LocalDateTime current = LocalDateTime.of(2027, 1, 3, 12, 0); long now = current.atZone(ZoneId.of("UTC")).toInstant().toEpochMilli(); @@ -167,8 +166,7 @@ void weeklyGenerationDoesNotDependOnTheJvmDefaultLocale() { current, now, false, true, false, 0); assertEquals(usGeneration, germanGeneration); - assertEquals(usLimit.value(), germanLimit.value()); - assertEquals(usLimit.expiresAt(), germanLimit.expiresAt()); + assertEquals(usLimit, germanLimit); } finally { Locale.setDefault(previous); } @@ -242,6 +240,59 @@ void sharedMysqlResetUsesTheJournalEpochTransaction() throws Exception { verify(refreshedUser).cache(); } + @Test + void perServerMysqlResetWipesAndAdvancesLegacySharedPurchaseEpochInOneTransaction() 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 schemaConnection = mock(Connection.class); + Connection resetConnection = mock(Connection.class); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement generation = mock(PreparedStatement.class); + PreparedStatement generationExpiry = mock(PreparedStatement.class); + PreparedStatement epochColumn = mock(PreparedStatement.class); + PreparedStatement epochTable = mock(PreparedStatement.class); + PreparedStatement epochGeneration = mock(PreparedStatement.class); + PreparedStatement index = mock(PreparedStatement.class); + PreparedStatement markerInsert = mock(PreparedStatement.class); + PreparedStatement markerSelect = mock(PreparedStatement.class); + PreparedStatement wipe = mock(PreparedStatement.class); + PreparedStatement advance = mock(PreparedStatement.class); + ResultSet epoch = mock(ResultSet.class); + when(table.getTableName()).thenReturn("PerServerUsers"); + when(table.qi(anyString())).thenAnswer(invocation -> "`" + invocation.getArgument(0) + "`"); + when(table.getMysql()).thenReturn(sql); + when(sql.getConnectionManager().getConnection()).thenReturn(schemaConnection, resetConnection); + when(schemaConnection.prepareStatement(anyString())).thenReturn(schema, generation, generationExpiry, + epochColumn, epochTable, epochGeneration, index); + when(resetConnection.prepareStatement(anyString())).thenReturn(markerInsert, markerSelect, wipe, advance); + when(epoch.next()).thenReturn(true); + when(epoch.getLong(1)).thenReturn(4L); + when(markerSelect.executeQuery()).thenReturn(epoch); + when(advance.executeUpdate()).thenReturn(1); + VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + when(plugin.getUserManager().getDataManager().getUserDataCache()) + .thenReturn(new java.util.concurrent.ConcurrentHashMap<>()); + + assertTrue(VoteShopPurchaseService.resetMysqlLimitWithPurchaseFence( + plugin, "VoteShopLimitdaily", "D:2026-09-13")); + + verify(table).checkColumn("VoteShopLimitdaily", com.bencodez.simpleapi.sql.DataType.INTEGER); + verify(resetConnection).commit(); + ArgumentCaptor sqlText = ArgumentCaptor.forClass(String.class); + verify(resetConnection, times(4)).prepareStatement(sqlText.capture()); + assertTrue(sqlText.getAllValues().stream().anyMatch(statement -> statement.contains("`VoteShopLimitdaily` = 0"))); + verify(wipe).executeUpdate(); + verify(advance).setLong(1, 5L); + verify(advance).setString(2, "D:2026-09-13"); + InOrder serializedReset = inOrder(markerSelect, wipe, advance, resetConnection); + serializedReset.verify(markerSelect).executeQuery(); + serializedReset.verify(wipe).executeUpdate(); + serializedReset.verify(advance).executeUpdate(); + serializedReset.verify(resetConnection).commit(); + } + @Test void sharedMysqlResetWaitsForAnInFlightCacheDump() throws Exception { CountDownLatch dumpEntered = new CountDownLatch(1); @@ -324,6 +375,68 @@ void sharedMysqlGuiValidationDoesNotReadOrRefreshDynamicUserState() { verify(user, never()).getPoints(); } + @Test + void perServerMysqlLimitedPurchaseStillDefersSharedLimitValidationToTheJournal() { + VotingPluginMain plugin = sharedMysqlPlugin(mock(MySQL.class)); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + VoteShopDefinition definition = mock(VoteShopDefinition.class); + when(definition.isEnabled()).thenReturn(true); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getPermission()).thenReturn(""); + when(item.getLimit()).thenReturn(1); + when(item.getIdentifier()).thenReturn("daily"); + when(item.getCost()).thenReturn(10); + VotingPluginUser user = purchaseUser(); + when(user.getVoteShopIdentifierLimit("daily")).thenReturn(1); + when(user.getPoints()).thenReturn(0); + + assertEquals(VoteShopPurchaseResult.SUCCESS, + new VoteShopPurchaseService(plugin, definition).validatePurchase(mock(org.bukkit.entity.Player.class), user, item)); + + verify(user, never()).getVoteShopIdentifierLimit(anyString()); + verify(user, never()).getPoints(); + } + + @Test + void claimedRewardUsesLegacyEntitySchedulerWhenFoliaIsUnavailable() { + VotingPluginMain plugin = mock(VotingPluginMain.class); + com.bencodez.simpleapi.scheduler.BukkitScheduler scheduler = + mock(com.bencodez.simpleapi.scheduler.BukkitScheduler.class); + RewardHandler rewardHandler = mock(RewardHandler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(scheduler.getFoliaLib()).thenReturn(null); + when(plugin.getRewardHandler()).thenReturn(rewardHandler); + when(plugin.getLogger()).thenReturn(mock(java.util.logging.Logger.class)); + ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); + when(plugin.getTimer()).thenReturn(persistenceExecutor); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(UUID.fromString("00000000-0000-0000-0000-000000000001")); + when(player.getName()).thenReturn("player"); + doAnswer(invocation -> { + invocation.getArgument(1, Runnable.class).run(); + return null; + }).when(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + VotingPluginUser user = purchaseUser(); + when(user.getPlayerName()).thenReturn("player"); + VoteShopItem item = mock(VoteShopItem.class); + when(item.getIdentifier()).thenReturn("daily"); + when(item.getCost()).thenReturn(10); + when(item.getRewardsPath()).thenReturn("Shop.daily.Rewards"); + when(item.getPurchaseMessage()).thenReturn("Purchased"); + SharedMysqlPurchaseJournal journal = mock(SharedMysqlPurchaseJournal.class); + VoteShopPurchaseService.SharedPurchaseDebit debit = new VoteShopPurchaseService.SharedPurchaseDebit( + VoteShopPurchaseResult.SUCCESS, journal, "purchase-1", "Points", "VoteShopLimitdaily"); + + try (org.mockito.MockedStatic bukkit = org.mockito.Mockito.mockStatic(org.bukkit.Bukkit.class)) { + bukkit.when(org.bukkit.Bukkit::getPluginManager).thenReturn(mock(org.bukkit.plugin.PluginManager.class)); + new VoteShopPurchaseService(plugin, mock(VoteShopDefinition.class)).scheduleClaimedReward(player, user, item, + new HashMap<>(), mock(FileConfiguration.class), ignored -> { }, debit); + } + + verify(scheduler).runTask(eq(plugin), any(Runnable.class), eq(player)); + verify(rewardHandler).giveReward(eq(user), any(FileConfiguration.class), eq("Shop.daily.Rewards"), any()); + } + @Test void rejectedInitialSharedMysqlSubmissionCompletesAsFailed() { MySQL table = mock(MySQL.class); @@ -995,11 +1108,12 @@ void sharedMysqlFailureReleasesDebitConnectionBeforeClassifyingTheLimit() throws } @Test - void sharedMysqlPurchaseQueuesDatabaseWorkOffCallingThread() throws Exception { + void perServerMysqlLimitedPurchaseQueuesJournalReservationOffCallingThread() 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); VotingPluginMain plugin = sharedMysqlPlugin(table); + when(plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); ScheduledExecutorService persistenceExecutor = mock(ScheduledExecutorService.class); when(plugin.getTimer()).thenReturn(persistenceExecutor); when(table.getMysql()).thenReturn(sql); From 85e8cdf86e5460643b00f3c2c70cc73056fa0acd Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:04:29 -0600 Subject: [PATCH 58/74] Protect offline point cache clearing --- .../votingplugin/user/VotingPluginUser.java | 25 +++++++++++++++---- .../user/SharedMysqlPointMutatorTest.java | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 40ccdb948..121048cb6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -34,6 +34,7 @@ import com.bencodez.advancedcore.api.user.AdvancedCoreUser; import com.bencodez.advancedcore.api.user.UserDataFetchMode; import com.bencodez.advancedcore.api.user.UserStorage; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.simpleapi.messages.MessageAPI; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; @@ -2595,8 +2596,22 @@ public String getVoteStreakState(String columnName) { return getData().getString(columnName); } - public void setVoteStreakState(String columnName, String value) { - getData().setString(columnName, value); - } - -} + public void setVoteStreakState(String columnName, String value) { + getData().setString(columnName, value); + } + + /** + * An asynchronous shared-MySQL point addition exposes a predicted value until + * its persistence task commits. A generic cache clear may dump the cache + * first, so remove only that still-current prediction before delegating to the + * normal flush-and-clear lifecycle. + */ + @Override + public void clearCache() { + if (!isCached()) return; + UserDataCache cache = getCache(); + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, getPointsPath()); + cache.clearCache(); + } + +} diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index cc2fcb5ff..81a92cc8e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -6,6 +6,7 @@ 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; @@ -471,6 +472,30 @@ void asynchronousAddDoesNotFlushItsOptimisticPointsPrediction() throws Exception 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); From 8818f2999829c18733f73e1631406684908442e2 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:24:34 -0600 Subject: [PATCH 59/74] Serialize optimistic cache clearing --- .../com/bencodez/votingplugin/user/VotingPluginUser.java | 6 ++++-- .../votingplugin/user/SharedMysqlPointMutatorTest.java | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 121048cb6..e776da6e6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -2610,8 +2610,10 @@ public void setVoteStreakState(String columnName, String value) { public void clearCache() { if (!isCached()) return; UserDataCache cache = getCache(); - SharedMysqlCacheReconciler.discardOptimisticPoint(cache, getPointsPath()); - cache.clearCache(); + synchronized (cache) { + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, getPointsPath()); + cache.clearCache(); + } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 81a92cc8e..54674576d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -485,6 +485,8 @@ void clearingAnOfflineUserCacheCannotFlushAnOptimisticPointsPrediction() { when(cache.getCache()).thenReturn(values); doCallRealMethod().when(user).clearCache(); doAnswer(invocation -> { + assertTrue(Thread.holdsLock(cache), + "prediction removal and cache clearing must share one cache critical section"); assertFalse(values.containsKey("Points"), "the prediction must be removed before clearCache can dump it"); return null; From 265d211b35a1de0fd454b1c8439252400c79ecb7 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:28:29 -0600 Subject: [PATCH 60/74] Avoid unnecessary cache monitor coupling --- .../com/bencodez/votingplugin/user/VotingPluginUser.java | 6 ++---- .../votingplugin/user/SharedMysqlPointMutatorTest.java | 2 -- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index e776da6e6..121048cb6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -2610,10 +2610,8 @@ public void setVoteStreakState(String columnName, String value) { public void clearCache() { if (!isCached()) return; UserDataCache cache = getCache(); - synchronized (cache) { - SharedMysqlCacheReconciler.discardOptimisticPoint(cache, getPointsPath()); - cache.clearCache(); - } + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, getPointsPath()); + cache.clearCache(); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 54674576d..81a92cc8e 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -485,8 +485,6 @@ void clearingAnOfflineUserCacheCannotFlushAnOptimisticPointsPrediction() { when(cache.getCache()).thenReturn(values); doCallRealMethod().when(user).clearCache(); doAnswer(invocation -> { - assertTrue(Thread.holdsLock(cache), - "prediction removal and cache clearing must share one cache critical section"); assertFalse(values.containsKey("Points"), "the prediction must be removed before clearCache can dump it"); return null; From 5bae2bd6562849b6f48eac69e4d68bcecf8d7b46 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:47:39 -0600 Subject: [PATCH 61/74] Retire completed admin point operations --- .../votingplugin/commands/CommandLoader.java | 31 +++++++++++-------- .../user/SharedPointAdditionJournal.java | 30 +++++++++++++----- .../user/SharedPointAdditionJournalTest.java | 13 ++++++-- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java index 58e203b55..78cb3b967 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/CommandLoader.java @@ -525,25 +525,30 @@ public void executeSinglePlayer(CommandSender sender, String[] args) { user.cache(); int amount = Integer.parseInt(args[3]); String operationId = "admin-points/" + UUID.randomUUID(); - user.addPointsStorageAwareAsync(amount, operationId).whenComplete((newTotal, failure) -> - runForVotingUser(user, () -> { - if (failure != null) { - runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( - "&cUnable to confirm the point addition; do not retry this command"))); - return; - } + user.addPointsStorageAwareAsync(amount, operationId).whenComplete((newTotal, failure) -> { + if (failure != null) { + runForVotingUser(user, () -> + runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize( + "&cUnable to confirm the point addition; do not retry this command")))); + return; + } + // Retire the durable admin operation independently of UI scheduling. If + // acknowledgement remains unavailable, its prefix makes the completed + // row eligible for bounded retention cleanup. + user.acknowledgeStorageAwarePointOperation(operationId) + .whenComplete((ignored, acknowledgementFailure) -> { + if (acknowledgementFailure != null) plugin.debug(acknowledgementFailure); + }); + runForVotingUser(user, () -> { if (user.isOnline()) { user.sendMessage(plugin.getConfigFile().getFormatCommandsAdminVotePointsPlayerGiven(), "amount", args[3]); } runForCommandSender(sender, () -> sender.sendMessage(MessageAPI.colorize("&cGave " + args[1] - + " " + args[3] + " points" + ", " + args[1] + " now has " + newTotal + " points"))); + + " " + args[3] + " points" + ", " + args[1] + " now has " + newTotal + " points"))); plugin.getPlaceholders().onUpdate(user, false); - user.acknowledgeStorageAwarePointOperation(operationId) - .whenComplete((ignored, acknowledgementFailure) -> { - if (acknowledgementFailure != null) plugin.debug(acknowledgementFailure); - }); - })); + }); + }); } }); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 520cc7342..72af6ea60 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -507,26 +507,40 @@ void acknowledge(String operationId, long now) throws SQLException { } } - /** Removes a bounded batch of replay-acknowledged entries after the retention window. */ + /** + * Removes a bounded batch after the retention window. Replay operations still + * require an explicit acknowledgement; ephemeral administrator operations may + * also retire directly from COMPLETED because their generated IDs are never + * reused after the command has reported its confirmed result. + */ void cleanupAcknowledged(long now) throws SQLException { long cutoff = now - COMPLETED_RETENTION_MILLIS; - String select = "SELECT " + qi("operation_id") + " FROM " + qiJournal() + " WHERE " + qi("state") - + " = ? AND " + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; + String select = "SELECT " + qi("operation_id") + " FROM " + qiJournal() + " WHERE (" + qi("state") + + " = ? OR (" + qi("state") + " = ? AND (" + qi("operation_id") + " LIKE ? OR " + + qi("operation_id") + " LIKE ? OR " + qi("operation_id") + " LIKE ? OR " + + qi("operation_id") + " LIKE ?))) AND " + + qi("created_at") + " <= ? ORDER BY " + qi("created_at") + " ASC LIMIT ?"; String delete = "DELETE FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? AND " - + qi("state") + " = ? AND " + qi("created_at") + " <= ?"; + + qi("created_at") + " <= ? AND (" + qi("state") + " = ? OR " + qi("state") + " = ?)"; try (Connection connection = connection(); PreparedStatement selectStatement = connection.prepareStatement(select); PreparedStatement deleteStatement = connection.prepareStatement(delete)) { selectStatement.setString(1, ACKNOWLEDGED); - selectStatement.setLong(2, cutoff); - selectStatement.setInt(3, CLEANUP_BATCH_SIZE); + selectStatement.setString(2, COMPLETED); + selectStatement.setString(3, "admin-points/%"); + selectStatement.setString(4, "admin-bulk-points/%"); + selectStatement.setString(5, "admin-bulk-remove/%"); + selectStatement.setString(6, "remove-points/%"); + selectStatement.setLong(7, cutoff); + selectStatement.setInt(8, CLEANUP_BATCH_SIZE); Set operationIds = new java.util.LinkedHashSet<>(); try (ResultSet result = selectStatement.executeQuery()) { while (result.next()) operationIds.add(result.getString(1)); } for (String operationId : operationIds) { deleteStatement.setString(1, operationId); - deleteStatement.setString(2, ACKNOWLEDGED); - deleteStatement.setLong(3, cutoff); + deleteStatement.setLong(2, cutoff); + deleteStatement.setString(3, ACKNOWLEDGED); + deleteStatement.setString(4, COMPLETED); deleteStatement.executeUpdate(); } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 5d8568f9d..d3845bce5 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -347,7 +347,7 @@ void journalTableNameRemainsPortableForLongSourceNames() { } @Test - void onlyReplayAcknowledgedEntriesAreExpiredInABoundedRetentionBatch() throws Exception { + void onlyAcknowledgedOrEphemeralAdminEntriesExpireInABoundedRetentionBatch() throws Exception { Fixture fixture = fixture(); PreparedStatement select = mock(PreparedStatement.class); PreparedStatement delete = mock(PreparedStatement.class); @@ -361,8 +361,15 @@ void onlyReplayAcknowledgedEntriesAreExpiredInABoundedRetentionBatch() throws Ex new SharedPointAdditionJournal(fixture.table, false).cleanupAcknowledged(now); verify(select).setString(1, "ACKNOWLEDGED"); - verify(select).setLong(2, now - SharedPointAdditionJournal.COMPLETED_RETENTION_MILLIS); - verify(select).setInt(3, 100); + 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(); } From 2e5e7d4a3658ac3d77c70460a01f93cc107f814d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:01:25 -0600 Subject: [PATCH 62/74] Release ambiguous unstarted point claims --- .../user/SharedPointAdditionJournal.java | 18 +++++++++-- .../user/SharedPointAdditionJournalTest.java | 31 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 72af6ea60..18d6186d8 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -203,8 +203,22 @@ && markExpiredHookIndeterminate(operationId, existing, uuid, pointsColumn, reque return HookClaim.claimedByCaller(); } catch (SQLException ambiguousCommit) { closeQuietly(connection); - AdditionRow confirmed = find(operationId); - if (confirmed != null) return claimForExisting(confirmed, uuid, pointsColumn, requestedAmount, owner); + try { + AdditionRow confirmed = find(operationId); + if (confirmed != null) { + return claimForExisting(confirmed, uuid, pointsColumn, requestedAmount, owner); + } + } catch (SQLException confirmationFailure) { + ambiguousCommit.addSuppressed(confirmationFailure); + } + // The receive hook has not been scheduled yet. Delete only this owner's + // exact HOOK_STARTED claim so an ambiguous insert cannot turn a safe + // retry into manual reconciliation. + try { + releaseUnstartedHook(operationId, uuid, pointsColumn, requestedAmount, owner); + } catch (SQLException releaseFailure) { + ambiguousCommit.addSuppressed(releaseFailure); + } throw ambiguousCommit; } } catch (SQLException failure) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index d3845bce5..4ee1ef953 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -195,6 +195,37 @@ void provenUnstartedHookClaimIsReleasedForASafeRetry() throws Exception { 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(); From ec0574ea737e9b69bd842242f125bd408a9f1670 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:12:17 -0600 Subject: [PATCH 63/74] Dispatch vote shop purchases synchronously --- .../events/VoteShopPurchaseEvent.java | 12 +++++++++- .../service/VoteShopPurchaseService.java | 2 +- .../events/VoteShopPurchaseEventTest.java | 22 +++++++++++++++++++ 3 files changed, 34 insertions(+), 2 deletions(-) create mode 100644 VotingPlugin/src/test/java/com/bencodez/votingplugin/events/VoteShopPurchaseEventTest.java diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java index d1bca0126..9b164576a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/events/VoteShopPurchaseEvent.java @@ -32,7 +32,17 @@ public class VoteShopPurchaseEvent extends Event { public VoteShopPurchaseEvent(UUID playerUUID, String playerName, VotingPluginUser user, String identifier, int cost) { - super(true); + this(playerUUID, playerName, user, identifier, cost, true); + } + + /** + * Creates a purchase event with the dispatch lane made explicit. Internal + * entity-lane purchases are synchronous; the legacy constructor remains + * asynchronous for source and behavioral compatibility with external callers. + */ + public VoteShopPurchaseEvent(UUID playerUUID, String playerName, VotingPluginUser user, String identifier, + int cost, boolean async) { + super(async); this.playerName = playerName; this.user = user; this.playerUuid = playerUUID; 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 4f6c895ca..b3169d14f 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 @@ -476,7 +476,7 @@ private void completePurchase(Player player, VotingPluginUser user, VoteShopItem 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); } 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 000000000..7df46ed23 --- /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()); + } +} From 83470a225a99ada5f7b83d555764fa39983ac4d9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:31:30 -0600 Subject: [PATCH 64/74] Preserve authoritative bulk point validation --- .../commands/gui/player/VoteShop.java | 2 +- .../commands/gui/player/VoteShopConfirm.java | 4 ++-- .../votingplugin/user/VotingPluginUser.java | 10 ++++++---- .../service/VoteShopPurchaseService.java | 8 +++++++- .../VotingPluginUserPointSchedulingTest.java | 19 +++++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 7 +++++-- 6 files changed, 40 insertions(+), 10 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java index 927b12272..1b14101fe 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShop.java @@ -126,7 +126,7 @@ protected void addItemButton(BInventory inv, final Player player, final VotingPl public void onClick(ClickEvent event) { VotingPluginUser clickedUser = getUser(event.getPlayer()); plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(clickedUser, - plugin.getConfigFile().isExtraVoteShopCheck()); + item, plugin.getConfigFile().isExtraVoteShopCheck()); if (item.isNotBuyable()) { clickedUser.sendMessage(plugin.getConfigFile().getFormatShopNotPurchasable()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java index 060d5fa17..d03c45126 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/commands/gui/player/VoteShopConfirm.java @@ -76,7 +76,7 @@ public void onChest(final Player player) { public void onClick(ClickEvent event) { if (!beginPurchase()) return; event.closeInventory(); - plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, true); + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, item, true); plugin.getVoteShopManager().purchase(player, user, item, result -> { if (result != VoteShopPurchaseResult.SUCCESS) { plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(player, user, item, result); @@ -127,7 +127,7 @@ public void onDialog(Player player) { return; } - plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, true); + plugin.getVoteShopManager().getPurchaseService().refreshUserForPurchaseValidation(user, item, true); plugin.getVoteShopManager().purchase(clicked, user, item, result -> { if (result != VoteShopPurchaseResult.SUCCESS) { plugin.getVoteShopManager().getPurchaseService().sendFailureMessage(clicked, user, item, diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 121048cb6..c5c7dd6f6 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -518,6 +518,7 @@ public static void addPointsStorageAware(VotingPluginMain plugin, List completion.accept(user, success)); } return; @@ -541,7 +542,7 @@ public static void addPointsStorageAware(VotingPluginMain plugin, List done.accept(false)); + (user, done) -> done.accept(false), false); } /** @@ -560,7 +561,7 @@ public static void setPointsStorageAware(VotingPluginMain plugin, List { user.setPoints(value); done.accept(true); - }); + }, false); } /** @@ -612,7 +613,7 @@ public static void removePointsStorageAware(VotingPluginMain plugin, List user.removePoints(value, done)); + (user, done) -> user.removePoints(value, done), true); } static String bulkPointOperationId(String prefix, String batchOperationId, String userId) { @@ -633,10 +634,11 @@ private interface OrdinaryPointMutation { private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List users, BiConsumer completion, SharedPointMutation sharedMutation, - OrdinaryPointMutation ordinaryMutation) { + OrdinaryPointMutation ordinaryMutation, boolean authoritativeReadRequired) { if (users.isEmpty()) return; if (!new SharedMysqlPointMutator(plugin).applies()) { for (VotingPluginUser user : users) { + if (authoritativeReadRequired) user.userDataFetechMode(UserDataFetchMode.NO_CACHE); ordinaryMutation.apply(user, success -> completion.accept(user, success)); } return; 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 b3169d14f..f1a3fb179 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 @@ -104,8 +104,14 @@ public VoteShopPurchaseResult validatePurchase(Player player, VotingPluginUser u } /** 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) { - if (requested && !usesMysqlPurchaseReservation(null)) user.cache(); + refreshUserForPurchaseValidation(user, null, requested); } private VoteShopPurchaseResult validateStaticPurchase(Player player, VoteShopItem item) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 13ca44cc7..025169173 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -64,6 +64,25 @@ void bulkPointOperationIdsAreDeterministicDistinctAndFitTheJournalSchema() { 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(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 96c946e16..18fd5964a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -366,7 +366,7 @@ void sharedMysqlGuiValidationDoesNotReadOrRefreshDynamicUserState() { VotingPluginUser user = mock(VotingPluginUser.class); VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, definition); - service.refreshUserForPurchaseValidation(user, true); + service.refreshUserForPurchaseValidation(user, item, true); VoteShopPurchaseResult result = service.validatePurchase(mock(org.bukkit.entity.Player.class), user, item); assertEquals(VoteShopPurchaseResult.SUCCESS, result); @@ -389,10 +389,13 @@ void perServerMysqlLimitedPurchaseStillDefersSharedLimitValidationToTheJournal() VotingPluginUser user = purchaseUser(); when(user.getVoteShopIdentifierLimit("daily")).thenReturn(1); when(user.getPoints()).thenReturn(0); + VoteShopPurchaseService service = new VoteShopPurchaseService(plugin, definition); + service.refreshUserForPurchaseValidation(user, item, true); assertEquals(VoteShopPurchaseResult.SUCCESS, - new VoteShopPurchaseService(plugin, definition).validatePurchase(mock(org.bukkit.entity.Player.class), user, item)); + service.validatePurchase(mock(org.bukkit.entity.Player.class), user, item)); + verify(user, never()).cache(); verify(user, never()).getVoteShopIdentifierLimit(anyString()); verify(user, never()).getPoints(); } From 6e4bebf81a17e7d13bc49f71e0773fd7991698f5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 19:59:08 -0600 Subject: [PATCH 65/74] Preserve point replay safety across mode changes --- .../user/SharedMysqlPointMutator.java | 4 +- .../votingplugin/user/VotingPluginUser.java | 68 +++++++++++ .../user/SharedMysqlPointMutatorTest.java | 33 ++++++ .../VotingPluginUserPointSchedulingTest.java | 106 ++++++++++++++++++ 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 39803fa73..587a9fa91 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -238,7 +238,7 @@ AddResult settleClaimedPointAddition(VotingPluginUser user, String operationId, } CompletionStage acknowledgePointAddition(String operationId) { - if (!applies() || operationId == null || operationId.isEmpty()) { + if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) { return CompletableFuture.completedFuture(null); } CompletableFuture completion = new CompletableFuture<>(); @@ -259,7 +259,7 @@ CompletionStage acknowledgePointAddition(String operationId) { } void acknowledgePointAdditionNow(String operationId) { - if (!applies() || operationId == null || operationId.isEmpty()) return; + if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) return; try { SharedPointAdditionJournal.forTable(plugin.getMysql()).acknowledge(operationId, System.currentTimeMillis()); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index c5c7dd6f6..99dd657a3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -288,6 +288,10 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); if (!sharedPoints.applies()) { + if (operationId != null && !operationId.isEmpty() + && SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) { + return addOrdinaryPointsAfterSharedReplayLookup(sharedPoints, value, operationId); + } PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); @@ -322,6 +326,70 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu return completion; } + /** + * A retry may arrive after PerServerPoints was enabled. Consult the durable + * shared-points journal before touching the now-local balance so an already + * committed global credit cannot be applied a second time. + */ + private CompletionStage addOrdinaryPointsAfterSharedReplayLookup(SharedMysqlPointMutator sharedPoints, + int value, String operationId) { + CompletableFuture completion = new CompletableFuture<>(); + String uuid = getUUID(); + String journalPointsPath = "Points"; + ReplayPointKey replayKey = new ReplayPointKey(plugin, operationId, uuid, journalPointsPath, value); + CompletableFuture existing = IN_FLIGHT_POINT_REPLAYS.putIfAbsent(replayKey, completion); + if (existing != null) return existing; + completion.whenComplete((ignored, failure) -> IN_FLIGHT_POINT_REPLAYS.remove(replayKey, completion)); + Player player = getPlayer(); + try { + plugin.getTimer().execute(() -> { + try { + Integer completed = sharedPoints.completedPointAdditionTotal(operationId, uuid, journalPointsPath); + if (completed != null) { + completion.complete(completed); + return; + } + BukkitCompletionScheduler.run(plugin, player, + () -> completeOrdinaryPointAddition(sharedPoints, value, completion), + () -> completion.completeExceptionally(new IllegalStateException( + "Unable to schedule point addition after replay lookup"))); + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + }); + } catch (RuntimeException rejected) { + plugin.debug(rejected); + completion.completeExceptionally(rejected); + } + return completion; + } + + private void completeOrdinaryPointAddition(SharedMysqlPointMutator sharedPoints, int value, + CompletableFuture completion) { + try { + synchronized (this) { + // The persistence mode may have changed while the journal lookup was in + // flight. A retry can safely restart through the shared journal path. + if (sharedPoints.applies()) { + completion.completeExceptionally( + new IllegalStateException("Point storage mode changed during replay lookup")); + return; + } + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + Bukkit.getPluginManager().callEvent(event); + if (event.isCancelled()) { + completion.complete(getPoints()); + return; + } + int newTotal = getPoints() + event.getPoints(); + setPoints(newTotal, false); + completion.complete(newTotal); + } + } catch (Throwable failure) { + completion.completeExceptionally(failure); + } + } + /** * Claims the durable reward-operation journal before returning to the Bukkit * lane for the receive event. JDBC therefore never blocks that lane, and the diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 81a92cc8e..0ec4deffd 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -23,6 +23,7 @@ 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; @@ -285,6 +286,38 @@ void transferRecoveryEligibilityIgnoresCurrentPerServerPointsSetting() { 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); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 025169173..52a6c889a 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -14,6 +14,7 @@ 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; @@ -474,6 +475,111 @@ void durableSharedAsyncRetryCompletesBeforeFiringTheReceiveEvent() throws Except 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("lobby_Points").when(fixture.user).getPointsPath(); + doReturn(10).when(fixture.user).getPoints(); + doNothing().when(fixture.user).setPoints(15, false); + PreparedStatement schema = mock(PreparedStatement.class); + PreparedStatement lookup = mock(PreparedStatement.class); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, lookup); + 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(); + assertEquals(15, completion.join()); + verify(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); + verify(fixture.user).setPoints(15, false); + } + } + + @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); + ResultSet missing = mock(ResultSet.class); + when(missing.next()).thenReturn(false); + when(lookup.executeQuery()).thenReturn(missing); + when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, lookup); + 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(); + + assertTrue(completion.isCompletedExceptionally()); + verifyNoInteractions(pluginManager); + verify(fixture.user, never()).setPoints(anyInt(), eq(false)); + } + } + @Test void retiredEntitySchedulerQueuesUnstartedHookReleaseWithoutJdbcOnCompletionLane() throws Exception { PointFixture fixture = pointFixture(); From 288dff89a7fa9947e72ee43253b271184b250465 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:13:57 -0600 Subject: [PATCH 66/74] Keep transfer player access on the entity lane --- .../user/SharedMysqlPointMutator.java | 183 ++++++++++-------- .../user/SharedMysqlPointMutatorTest.java | 11 +- .../VotingPluginUserPointSchedulingTest.java | 17 +- 3 files changed, 114 insertions(+), 97 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 587a9fa91..0f00605b2 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -428,76 +428,81 @@ boolean transfer(VotingPluginUser source, VotingPluginUser target, int debitAmou */ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, IntFunction creditAmountProvider, Consumer completion) { + org.bukkit.entity.Player sourcePlayer = source.getPlayer(); + org.bukkit.entity.Player targetPlayer = target.getPlayer(); + org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer; try { plugin.getTimer().execute(() -> { - try { - drainCache(source); - drainCache(target); - } catch (RuntimeException cacheFailure) { - plugin.debug(cacheFailure); - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); - return; - } - MySQL table = plugin.getMysql(); - String sourcePoints = source.getPointsPath(); - String targetPoints = target.getPointsPath(); - String transferId = UUID.randomUUID().toString(); - String owner = UUID.randomUUID().toString(); - SharedPointTransferJournal journal; - try { - journal = SharedPointTransferJournal.forTable(table); - recoverTransfers(plugin, journal); - if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), debitAmount, - System.currentTimeMillis())) { - completeOnBukkit(source, completion, PointTransferResult.INSUFFICIENT_POINTS); + try { + drainCache(source); + drainCache(target); + } catch (RuntimeException cacheFailure) { + plugin.debug(cacheFailure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); return; } - // The source cache may have been recreated while the reservation was being - // committed. Invalidate its points after the durable debit, before any later - // dump can restore the pre-debit balance. - discardPointsCache(source, sourcePoints); - } catch (SQLException failure) { - // An acknowledgement/confirmation failure can follow a committed - // reservation debit. Never allow a cache recreated during the unknown - // outcome to flush the pre-debit source balance over it. - discardPointsCache(source, sourcePoints); - logFailure(failure); - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); - return; - } - - /* - * Do not claim the reservation until the Bukkit approval task has actually - * started. If scheduling is rejected, the row remains RESERVED and startup - * recovery can safely return the debit. JDBC claim work remains on the - * persistence executor, never on the Bukkit lane. - */ - try { - plugin.getBukkitScheduler().runTask(plugin, () -> { - try { - plugin.getTimer().execute(() -> claimTransferForApproval(source, target, debitAmount, - creditAmountProvider, completion, journal, transferId, owner, sourcePoints, targetPoints)); - } catch (RuntimeException schedulingFailure) { - // This callback is on Bukkit's lane. The durable RESERVED row is - // intentionally left for the bounded periodic/startup recovery instead - // of running its JDBC refund inline after executor rejection. - completeRejectedPersistenceSubmission(source, completion, schedulingFailure); + MySQL table = plugin.getMysql(); + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); + String transferId = UUID.randomUUID().toString(); + String owner = UUID.randomUUID().toString(); + SharedPointTransferJournal journal; + try { + journal = SharedPointTransferJournal.forTable(table); + recoverTransfers(plugin, journal); + if (!journal.reserve(transferId, source.getUUID(), sourcePoints, debitAmount, target.getUUID(), + debitAmount, System.currentTimeMillis())) { + completeOnBukkit(sourcePlayer, completion, PointTransferResult.INSUFFICIENT_POINTS); + return; } - }); - } catch (RuntimeException schedulingFailure) { - refundReservedAfterSchedulingFailure(source, completion, journal, transferId, sourcePoints, debitAmount, - schedulingFailure); - } + // The source cache may have been recreated while the reservation was being + // committed. Invalidate its points after the durable debit, before any later + // dump can restore the pre-debit balance. + discardPointsCache(source, sourcePoints); + } catch (SQLException failure) { + // An acknowledgement/confirmation failure can follow a committed + // reservation debit. Never allow a cache recreated during the unknown + // outcome to flush the pre-debit source balance over it. + discardPointsCache(source, sourcePoints); + logFailure(failure); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); + return; + } + + /* + * Do not claim the reservation until the Bukkit approval task has actually + * started. If scheduling is rejected, the row remains RESERVED and startup + * recovery can safely return the debit. JDBC claim work remains on the + * persistence executor, never on the Bukkit lane. + */ + try { + plugin.getBukkitScheduler().runTask(plugin, () -> { + try { + plugin.getTimer().execute(() -> claimTransferForApproval(source, target, sourcePlayer, + approvalPlayer, debitAmount, creditAmountProvider, completion, journal, transferId, + owner, sourcePoints, targetPoints)); + } catch (RuntimeException schedulingFailure) { + // This callback is on Bukkit's lane. The durable RESERVED row is + // intentionally left for the bounded periodic/startup recovery instead + // of running its JDBC refund inline after executor rejection. + completeRejectedPersistenceSubmission(sourcePlayer, completion, schedulingFailure); + } + }); + } catch (RuntimeException schedulingFailure) { + refundReservedAfterSchedulingFailure(source, sourcePlayer, completion, journal, transferId, + sourcePoints, debitAmount, schedulingFailure); + } }); } catch (RuntimeException schedulingFailure) { // No reservation exists when the initial persistence task is rejected. // Still complete the command contract on the source entity lane. plugin.debug(schedulingFailure); - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } } - private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser target, int debitAmount, + private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser target, + org.bukkit.entity.Player sourcePlayer, org.bukkit.entity.Player approvalPlayer, int debitAmount, IntFunction creditAmountProvider, Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints) { SharedPointTransferJournal.ClaimOutcome claim = journal.claimHookWithConfirmation(transferId, owner, @@ -509,24 +514,23 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser } catch (SQLException failure) { logFailure(failure); } - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); return; } if (claim == SharedPointTransferJournal.ClaimOutcome.INDETERMINATE) { // The approval task has not been submitted yet, so an ambiguous claim // cannot have invoked the recipient hook. Compensate the durable claim // instead of reporting success and leaving a HOOK_STARTED debit behind. - refundIndeterminateClaimBeforeApproval(source, completion, journal, transferId, sourcePoints, + refundIndeterminateClaimBeforeApproval(source, sourcePlayer, completion, journal, transferId, sourcePoints, debitAmount); return; } discardPointsCache(source, sourcePoints); - org.bukkit.entity.Player targetPlayer = target.getPlayer(); - org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : source.getPlayer(); AtomicInteger approvalState = new AtomicInteger(0); Runnable rejectBeforeStart = () -> { if (!approvalState.compareAndSet(0, 2)) return; - scheduleRejectedTransferCompensation(source, completion, journal, transferId, sourcePoints, debitAmount); + scheduleRejectedTransferCompensation(source, sourcePlayer, completion, journal, transferId, sourcePoints, + debitAmount); }; try { runTransferApprovalEntityTask(approvalPlayer, () -> { @@ -540,18 +544,18 @@ private void claimTransferForApproval(VotingPluginUser source, VotingPluginUser } Integer finalApprovedAmount = approvedAmount; try { - plugin.getTimer().execute(() -> settleTransfer(source, target, debitAmount, completion, journal, - transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); + plugin.getTimer().execute(() -> settleTransfer(source, target, sourcePlayer, debitAmount, completion, + journal, transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); } catch (RuntimeException schedulingFailure) { plugin.debug(schedulingFailure); try { plugin.getBukkitScheduler().runTaskAsynchronously(plugin, - () -> settleTransfer(source, target, debitAmount, completion, journal, transferId, owner, - sourcePoints, targetPoints, finalApprovedAmount)); + () -> settleTransfer(source, target, sourcePlayer, debitAmount, completion, journal, + transferId, owner, sourcePoints, targetPoints, finalApprovedAmount)); } catch (RuntimeException asyncSchedulingFailure) { plugin.debug(asyncSchedulingFailure); logIndeterminateClaim(transferId); - completeOnBukkit(source, completion, PointTransferResult.PENDING_CONFIRMATION); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.PENDING_CONFIRMATION); } } finally { approvalState.set(2); @@ -576,9 +580,10 @@ private void runTransferApprovalEntityTask(org.bukkit.entity.Player player, Runn }); } - private void scheduleRejectedTransferCompensation(VotingPluginUser source, Consumer completion, + private void scheduleRejectedTransferCompensation(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { - Runnable compensation = () -> compensateRejectedTransfer(source, completion, journal, transferId, + Runnable compensation = () -> compensateRejectedTransfer(source, sourcePlayer, completion, journal, transferId, sourcePoints, debitAmount); try { plugin.getTimer().execute(compensation); @@ -589,7 +594,7 @@ private void scheduleRejectedTransferCompensation(VotingPluginUser source, Consu } catch (RuntimeException asyncRejected) { plugin.debug(asyncRejected); rememberPendingCompensationMarker(plugin, transferId); - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } } } @@ -630,13 +635,14 @@ private static SharedPointTransferCompensationStore compensationStore(VotingPlug return new SharedPointTransferCompensationStore(plugin.getDataFolder().toPath()); } - private void compensateRejectedTransfer(VotingPluginUser source, Consumer completion, + private void compensateRejectedTransfer(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { try { // The CAS fence proves the approval callback cannot run. Write the // recoverable state before relying on completion delivery. if (!journal.markCompensating(transferId)) { - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); return; } } catch (SQLException markerFailure) { @@ -654,14 +660,15 @@ private void compensateRejectedTransfer(VotingPluginUser source, Consumer completion, SharedPointTransferJournal journal, String transferId, String owner, String sourcePoints, String targetPoints, Integer approvedAmount) { PointTransferResult result; @@ -685,10 +692,11 @@ private void settleTransfer(VotingPluginUser source, VotingPluginUser target, in plugin.debug(failure); result = PointTransferResult.PENDING_CONFIRMATION; } - completeOnBukkit(source, completion, result); + completeOnBukkit(sourcePlayer, completion, result); } - private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + private void refundReservedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { @@ -699,10 +707,11 @@ private void refundReservedAfterSchedulingFailure(VotingPluginUser source, Consu logFailure(refundFailure); } plugin.debug(failure); - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } - private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, Consumer completion, + private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount) { boolean refunded = false; try { @@ -731,16 +740,18 @@ private void refundIndeterminateClaimBeforeApproval(VotingPluginUser source, Con rememberPendingCompensationMarker(plugin, transferId); logIndeterminateClaim(transferId); } - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } - void completeRejectedPersistenceSubmission(VotingPluginUser source, Consumer completion, + void completeRejectedPersistenceSubmission(org.bukkit.entity.Player sourcePlayer, + Consumer completion, RuntimeException failure) { plugin.debug(failure); - completeOnBukkit(source, completion, PointTransferResult.UNAVAILABLE); + completeOnBukkit(sourcePlayer, completion, PointTransferResult.UNAVAILABLE); } - void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + void refundClaimedAfterSchedulingFailure(VotingPluginUser source, org.bukkit.entity.Player sourcePlayer, + Consumer completion, SharedPointTransferJournal journal, String transferId, String sourcePoints, int debitAmount, RuntimeException failure) { try { @@ -754,7 +765,7 @@ void refundClaimedAfterSchedulingFailure(VotingPluginUser source, Consumer completion, + private void completeOnBukkit(org.bukkit.entity.Player sourcePlayer, Consumer completion, PointTransferResult result) { - BukkitCompletionScheduler.run(plugin, source.getPlayer(), () -> completion.accept(result)); + BukkitCompletionScheduler.run(plugin, sourcePlayer, () -> completion.accept(result)); } private void logFailure(SQLException failure) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 0ec4deffd..6653187f8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -78,11 +78,12 @@ void transferApprovalUsesLegacyEntitySchedulerWhenFoliaIsUnavailable() throws Ex java.util.function.Consumer resultConsumer = completion::set; Method claim = SharedMysqlPointMutator.class.getDeclaredMethod("claimTransferForApproval", - VotingPluginUser.class, VotingPluginUser.class, int.class, java.util.function.IntFunction.class, - java.util.function.Consumer.class, SharedPointTransferJournal.class, String.class, String.class, - String.class, String.class); + 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, 10, + 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"); @@ -213,7 +214,7 @@ void indeterminateClaimedRefundStillInvalidatesSourcePoints() throws Exception { .thenThrow(new java.sql.SQLException("lost acknowledgement and confirmation")); AtomicReference result = new AtomicReference<>(); - new SharedMysqlPointMutator(plugin).refundClaimedAfterSchedulingFailure(source, result::set, journal, + new SharedMysqlPointMutator(plugin).refundClaimedAfterSchedulingFailure(source, null, result::set, journal, "transfer-1", "Points", 10, new RejectedExecutionException("worker stopped")); assertFalse(values.containsKey("Points")); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 52a6c889a..932f3c849 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -969,14 +969,17 @@ void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() thro }).when(pluginManager).callEvent(any(PlayerReceivePointsEvent.class)); fixture.user.transferPoints(fixture.target, 10, result::set); + verify(fixture.user).getPlayer(); + verify(fixture.target).getPlayer(); + org.mockito.Mockito.clearInvocations(fixture.user, fixture.target); ArgumentCaptor firstPersistence = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.persistence).execute(firstPersistence.capture()); - firstPersistence.getValue().run(); + verify(fixture.persistence).execute(firstPersistence.capture()); + firstPersistence.getValue().run(); - ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); - verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); - verify(fixture.claim, never()).prepareStatement(any(String.class)); - assertEquals(null, result.get()); + ArgumentCaptor approval = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.scheduler).runTask(eq(fixture.plugin), approval.capture()); + verify(fixture.claim, never()).prepareStatement(any(String.class)); + assertEquals(null, result.get()); approval.getValue().run(); ArgumentCaptor persistence = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence, org.mockito.Mockito.times(2)).execute(persistence.capture()); @@ -990,6 +993,8 @@ void sharedTransferRunsRecipientApprovalOnBukkitSchedulerBeforeSettlement() thro ArgumentCaptor settlement = ArgumentCaptor.forClass(Runnable.class); verify(fixture.persistence, org.mockito.Mockito.times(3)).execute(settlement.capture()); settlement.getAllValues().get(2).run(); + verify(fixture.user, never()).getPlayer(); + verify(fixture.target, never()).getPlayer(); } assertEquals(null, result.get()); From f3cd9eaaeb51a03bfb9b79777340a2e982bd7ba5 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:19:28 -0600 Subject: [PATCH 67/74] Settle PerServer reward points atomically with shared journal --- .../user/SharedMysqlPointMutator.java | 26 +++- .../user/SharedPointAdditionJournal.java | 135 +++++++++-------- .../votingplugin/user/VotingPluginUser.java | 102 +++++++++---- .../user/SharedPointAdditionJournalTest.java | 68 ++++++++- .../VotingPluginUserPointSchedulingTest.java | 138 +++++++++++++++++- 5 files changed, 374 insertions(+), 95 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 0f00605b2..db0e26a92 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -227,7 +227,7 @@ AddResult settleClaimedPointAddition(VotingPluginUser user, String operationId, drainCache(user); try { SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) - .settleClaim(operationId, uuid, pointsColumn, requestedAmount, owner, adjustedAmount); + .settleClaim(operationId, uuid, pointsColumn, pointsColumn, requestedAmount, owner, adjustedAmount); return new AddResult(true, result.total()); } catch (SQLException failure) { logFailure(failure); @@ -237,6 +237,24 @@ AddResult settleClaimedPointAddition(VotingPluginUser user, String operationId, } } + /** Atomically records a PerServerPoints hook outcome and credits its local column. */ + AddResult settlePerServerClaimedPointAddition(VotingPluginUser user, String operationId, String uuid, + String journalPointsColumn, String creditPointsColumn, int requestedAmount, String owner, + Integer adjustedAmount) { + drainCache(user, creditPointsColumn); + try { + SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) + .settleClaim(operationId, uuid, journalPointsColumn, creditPointsColumn, requestedAmount, owner, + adjustedAmount); + return new AddResult(true, result.total()); + } catch (SQLException failure) { + logFailure(failure); + return new AddResult(false, 0); + } finally { + discardPointsCache(user, creditPointsColumn); + } + } + CompletionStage acknowledgePointAddition(String operationId) { if (!canRecoverSharedMysqlPointJournals(plugin) || operationId == null || operationId.isEmpty()) { return CompletableFuture.completedFuture(null); @@ -982,12 +1000,16 @@ private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { } private void drainCache(VotingPluginUser user) { + drainCache(user, user.getPointsPath()); + } + + private void drainCache(VotingPluginUser user, String pointsColumn) { SharedMysqlCacheReconciler.withCacheDumpFence(() -> { if (!user.isCached()) return; UserDataCache cache = user.getCache(); if (cache == null) return; synchronized (cache) { - SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, pointsColumn); cache.dump(); plugin.getUserManager().getDataManager().removeCache(UUID.fromString(user.getUUID()), null); } diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 18d6186d8..ef2c37969 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -229,15 +229,22 @@ && markExpiredHookIndeterminate(operationId, existing, uuid, pointsColumn, reque } } - /** Completes a claimed hook exactly once, including a durable cancellation outcome. */ - AdditionResult settleClaim(String operationId, String uuid, String pointsColumn, int requestedAmount, String owner, - Integer adjustedAmount) throws SQLException { - if (!isSafeColumn(pointsColumn)) throw new SQLException("Unsafe shared point column"); + /** + * Completes a claimed hook exactly once, including a durable cancellation + * outcome. The journal identity is kept separate from the physical credit + * column so a PerServerPoints hook can atomically credit its local column + * while retaining the shared {@code Points} replay identity. + */ + AdditionResult settleClaim(String operationId, String uuid, String journalPointsColumn, String creditPointsColumn, + int requestedAmount, String owner, Integer adjustedAmount) throws SQLException { + if (!isSafeColumn(journalPointsColumn) || !isSafeColumn(creditPointsColumn)) { + throw new SQLException("Unsafe shared point column"); + } String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? FOR UPDATE"; - String points = qi(pointsColumn); + String points = qi(creditPointsColumn); String updatePoints = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + " + ? WHERE " + qi("uuid") + uuidCast(); String readPoints = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") @@ -246,76 +253,80 @@ AdditionResult settleClaim(String operationId, String uuid, String pointsColumn, + " = ?, " + qi("total_points") + " = ? WHERE " + qi("operation_id") + " = ?"; try (Connection connection = connection()) { connection.setAutoCommit(false); - try (PreparedStatement selectStatement = connection.prepareStatement(select)) { - selectStatement.setString(1, operationId); - AdditionRow row; - try (ResultSet result = selectStatement.executeQuery()) { - if (!result.next()) { + try { + try (PreparedStatement selectStatement = connection.prepareStatement(select)) { + selectStatement.setString(1, operationId); + AdditionRow row; + try (ResultSet result = selectStatement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point addition claim disappeared"); + } + Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); + Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); + row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), + total, requested, result.getString(7), result.getLong(8)); + } + HookClaim resolved = claimForExisting(row, uuid, journalPointsColumn, requestedAmount, owner); + if (resolved.completed()) { rollback(connection); - throw new SQLException("Shared point addition claim disappeared"); + return new AdditionResult(resolved.total()); + } + if (!resolved.claimed()) { + rollback(connection); + throw new SQLException("Shared point addition claim is not owned by this operation"); } - Integer total = result.getObject(5) == null ? null : Integer.valueOf(result.getInt(5)); - Integer requested = result.getObject(6) == null ? null : Integer.valueOf(result.getInt(6)); - row = new AdditionRow(result.getString(1), result.getString(2), result.getInt(3), result.getString(4), - total, requested, result.getString(7), result.getLong(8)); - } - HookClaim resolved = claimForExisting(row, uuid, pointsColumn, requestedAmount, owner); - if (resolved.completed()) { - rollback(connection); - return new AdditionResult(resolved.total()); } - if (!resolved.claimed()) { - rollback(connection); - throw new SQLException("Shared point addition claim is not owned by this operation"); + if (adjustedAmount != null) { + try (PreparedStatement statement = connection.prepareStatement(updatePoints)) { + statement.setInt(1, adjustedAmount.intValue()); + statement.setString(2, uuid); + if (statement.executeUpdate() != 1) { + rollback(connection); + throw new SQLException("Shared point user row missing"); + } + } } - } - if (adjustedAmount != null) { - try (PreparedStatement statement = connection.prepareStatement(updatePoints)) { - statement.setInt(1, adjustedAmount.intValue()); - statement.setString(2, uuid); - if (statement.executeUpdate() != 1) { - rollback(connection); - throw new SQLException("Shared point user row missing"); + int total; + try (PreparedStatement statement = connection.prepareStatement(readPoints)) { + statement.setString(1, uuid); + try (ResultSet result = statement.executeQuery()) { + if (!result.next()) { + rollback(connection); + throw new SQLException("Shared point user row disappeared"); + } + total = result.getInt(1); } } - } - int total; - try (PreparedStatement statement = connection.prepareStatement(readPoints)) { - statement.setString(1, uuid); - try (ResultSet result = statement.executeQuery()) { - if (!result.next()) { + try (PreparedStatement statement = connection.prepareStatement(complete)) { + // amount is a durable, non-null actual credit. A cancelled hook has + // no credit, rather than a nullable/ambiguous amount. + statement.setInt(1, adjustedAmount == null ? 0 : adjustedAmount.intValue()); + statement.setString(2, COMPLETED); + statement.setInt(3, total); + statement.setString(4, operationId); + if (statement.executeUpdate() != 1) { rollback(connection); - throw new SQLException("Shared point user row disappeared"); + throw new SQLException("Shared point addition journal row missing"); } - total = result.getInt(1); } - } - try (PreparedStatement statement = connection.prepareStatement(complete)) { - // amount is a durable, non-null actual credit. A cancelled hook has - // no credit, rather than a nullable/ambiguous amount. - statement.setInt(1, adjustedAmount == null ? 0 : adjustedAmount.intValue()); - statement.setString(2, COMPLETED); - statement.setInt(3, total); - statement.setString(4, operationId); - if (statement.executeUpdate() != 1) { - rollback(connection); - throw new SQLException("Shared point addition journal row missing"); + try { + connection.commit(); + return new AdditionResult(total); + } catch (SQLException ambiguousCommit) { + closeQuietly(connection); + AdditionRow confirmed = find(operationId); + HookClaim resolved = confirmed == null ? null + : claimForExisting(confirmed, uuid, journalPointsColumn, requestedAmount, owner); + if (resolved != null && resolved.completed()) return new AdditionResult(resolved.total()); + throw ambiguousCommit; } - } - try { - connection.commit(); - return new AdditionResult(total); - } catch (SQLException ambiguousCommit) { - closeQuietly(connection); - AdditionRow confirmed = find(operationId); - HookClaim resolved = confirmed == null ? null - : claimForExisting(confirmed, uuid, pointsColumn, requestedAmount, owner); - if (resolved != null && resolved.completed()) return new AdditionResult(resolved.total()); - throw ambiguousCommit; + } catch (SQLException failure) { + rollback(connection); + throw failure; } } } - private HookClaim claimForExisting(AdditionRow row, String uuid, String pointsColumn, int requestedAmount, String owner) throws SQLException { if (!row.matchesTarget(uuid, pointsColumn) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 99dd657a3..a541a35dd 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -290,7 +290,7 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu if (!sharedPoints.applies()) { if (operationId != null && !operationId.isEmpty() && SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) { - return addOrdinaryPointsAfterSharedReplayLookup(sharedPoints, value, operationId); + return addPerServerPointsWithReplayClaim(sharedPoints, value, operationId); } PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); @@ -327,11 +327,11 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu } /** - * A retry may arrive after PerServerPoints was enabled. Consult the durable - * shared-points journal before touching the now-local balance so an already - * committed global credit cannot be applied a second time. + * Claims the shared reward-operation journal before invoking a PerServerPoints + * receive hook. The journal prevents another backend from running that hook + * and creating a second server-local credit for the same operation. */ - private CompletionStage addOrdinaryPointsAfterSharedReplayLookup(SharedMysqlPointMutator sharedPoints, + private CompletionStage addPerServerPointsWithReplayClaim(SharedMysqlPointMutator sharedPoints, int value, String operationId) { CompletableFuture completion = new CompletableFuture<>(); String uuid = getUUID(); @@ -341,18 +341,31 @@ private CompletionStage addOrdinaryPointsAfterSharedReplayLookup(Shared if (existing != null) return existing; completion.whenComplete((ignored, failure) -> IN_FLIGHT_POINT_REPLAYS.remove(replayKey, completion)); Player player = getPlayer(); + String claimOwner = UUID.randomUUID().toString(); try { plugin.getTimer().execute(() -> { try { - Integer completed = sharedPoints.completedPointAdditionTotal(operationId, uuid, journalPointsPath); - if (completed != null) { - completion.complete(completed); + SharedPointAdditionJournal.HookClaim claim = sharedPoints.claimPointAdditionHook(operationId, uuid, + journalPointsPath, value, claimOwner); + if (claim.completed()) { + completion.complete(claim.total()); + return; + } + if (!claim.claimed()) { + if (claim.requiresReconciliation()) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, null); + } else { + completion.completeExceptionally(new IllegalStateException( + "Shared MySQL point addition is already being confirmed")); + } return; } BukkitCompletionScheduler.run(plugin, player, - () -> completeOrdinaryPointAddition(sharedPoints, value, completion), - () -> completion.completeExceptionally(new IllegalStateException( - "Unable to schedule point addition after replay lookup"))); + () -> completePerServerPointAddition(sharedPoints, value, operationId, uuid, journalPointsPath, + claimOwner, completion), + () -> releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion)); } catch (Throwable failure) { completion.completeExceptionally(failure); } @@ -364,29 +377,68 @@ private CompletionStage addOrdinaryPointsAfterSharedReplayLookup(Shared return completion; } - private void completeOrdinaryPointAddition(SharedMysqlPointMutator sharedPoints, int value, - CompletableFuture completion) { + private void completePerServerPointAddition(SharedMysqlPointMutator sharedPoints, int value, String operationId, + String uuid, String journalPointsPath, String claimOwner, CompletableFuture completion) { + boolean hookStarted = false; try { + String localPointsPath; + Integer adjusted; synchronized (this) { - // The persistence mode may have changed while the journal lookup was in - // flight. A retry can safely restart through the shared journal path. + // A pre-hook mode flip proves no listener has run, so release the exact + // claim rather than allowing a shared-points retry to see a live owner. if (sharedPoints.applies()) { - completion.completeExceptionally( - new IllegalStateException("Point storage mode changed during replay lookup")); + releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion); return; } PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + hookStarted = true; Bukkit.getPluginManager().callEvent(event); - if (event.isCancelled()) { - completion.complete(getPoints()); - return; - } - int newTotal = getPoints() + event.getPoints(); - setPoints(newTotal, false); - completion.complete(newTotal); + // Capture the local key and listener-adjusted delta while still on the + // entity lane. The persistence transaction reads and credits that key. + localPointsPath = getPointsPath(); + adjusted = event.isCancelled() ? null : Integer.valueOf(event.getPoints()); + } + String capturedLocalPointsPath = localPointsPath; + Integer capturedAdjusted = adjusted; + try { + plugin.getTimer().execute(() -> { + try { + // The receive hook already ran. Do not write to a potentially shared + // column if a reload changed the mode or local key after that hook. + if (sharedPoints.applies() || !capturedLocalPointsPath.equals(getPointsPath())) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, + value, claimOwner, completion, new IllegalStateException( + "Point storage mode changed after the PerServerPoints receive hook")); + return; + } + // This transaction credits the local column and completes the shared + // journal together. It bypasses legacy UserData writes, which cannot + // report a durable MySQL failure to this caller. + SharedMysqlPointMutator.AddResult result = sharedPoints.settlePerServerClaimedPointAddition( + this, operationId, uuid, journalPointsPath, capturedLocalPointsPath, value, claimOwner, + capturedAdjusted); + if (result.success()) completion.complete(result.total()); + else reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, + value, claimOwner, completion, new IllegalStateException( + "Unable to persist PerServerPoints reward completion")); + } catch (Throwable failure) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, failure); + } + }); + } catch (RuntimeException rejected) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, rejected); } } catch (Throwable failure) { - completion.completeExceptionally(failure); + if (hookStarted) { + reportIndeterminateSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion, failure); + } else { + releaseUnstartedSharedPointAddition(sharedPoints, operationId, uuid, journalPointsPath, value, + claimOwner, completion); + } } } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 4ee1ef953..0156145c0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -328,13 +328,79 @@ void cancelledHookSettlesWithARepresentableZeroCredit() throws Exception { when(fixture.initialLookup.prepareStatement(anyString())).thenReturn(select, read, complete); assertEquals(12, new SharedPointAdditionJournal(fixture.table, false).settleClaim("reward-operation", - "player", "Points", 5, "owner", null).total()); + "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` = `lobby_Points` + ?"))); + 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 Points", 5, "owner", + Integer.valueOf(3))); + verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.never()).getConnection(); + } + @Test void claimedHookRejectsAConflictingRequestedAmountBeforeAnotherEventCanRun() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 932f3c849..3afb239ee 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -27,9 +27,11 @@ 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; @@ -516,14 +518,40 @@ void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() th PointFixture fixture = pointFixture(); when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); doReturn("lobby_Points").when(fixture.user).getPointsPath(); - doReturn(10).when(fixture.user).getPoints(); - doNothing().when(fixture.user).setPoints(15, false); 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); - when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, lookup); + 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)) { @@ -543,9 +571,84 @@ void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() th verify(fixture.user, never()).setPoints(anyInt(), eq(false)); bukkitWork.getValue().run(); + assertFalse(completion.isDone()); + 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(any(PlayerReceivePointsEvent.class)); - verify(fixture.user).setPoints(15, false); + verify(settleCredit).setInt(1, 5); + verify(settleCredit).setString(2, "00000000-0000-0000-0000-000000000001"); + verify(claimInsert).setString(3, "Points"); + } + } + + @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)); } } @@ -556,10 +659,31 @@ void perServerPointAdditionFailsWithoutWritingWhenBukkitHandoffIsRejected() thro 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); - when(fixture.connection.prepareStatement(anyString())).thenReturn(schema, schema, lookup); + 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) @@ -574,9 +698,13 @@ void perServerPointAdditionFailsWithoutWritingWhenBukkitHandoffIsRejected() thro 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"); } } From 0998dac18a8cb4463a48ffd7396a8d6db7dea24d Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:26:48 -0600 Subject: [PATCH 68/74] Accept quoted legacy per-server points columns --- .../votingplugin/user/SharedPointAdditionJournal.java | 5 ++++- .../votingplugin/user/SharedPointAdditionJournalTest.java | 2 +- .../user/VotingPluginUserPointSchedulingTest.java | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index ef2c37969..0449ada51 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -657,7 +657,10 @@ private static String hash(String value) { } private static boolean isSafeColumn(String column) { - return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + // AbstractSqlTable.qi escapes the dialect's identifier delimiter. Existing + // PerServerPoints names may begin with a digit or contain spaces; keep + // those legacy columns usable while bounding the journal value. + return column != null && !column.isEmpty() && column.length() <= 128 && column.indexOf('\0') < 0; } private Connection connection() throws SQLException { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 0156145c0..c0e33bd25 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -396,7 +396,7 @@ void settlementRejectsAnUnsafePhysicalCreditColumnBeforeOpeningSql() throws Exce Fixture fixture = fixture(); assertThrows(java.sql.SQLException.class, () -> new SharedPointAdditionJournal(fixture.table, false) - .settleClaim("reward-operation", "player", "Points", "lobby Points", 5, "owner", + .settleClaim("reward-operation", "player", "Points", "lobby\0Points", 5, "owner", Integer.valueOf(3))); verify(fixture.sql.getConnectionManager(), org.mockito.Mockito.never()).getConnection(); } diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 3afb239ee..87163c0f0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -517,7 +517,7 @@ void durableSharedAsyncRetrySurvivesSwitchToPerServerPoints() throws Exception { void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() throws Exception { PointFixture fixture = pointFixture(); when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); - doReturn("lobby_Points").when(fixture.user).getPointsPath(); + doReturn("1Lobby West_Points").when(fixture.user).getPointsPath(); PreparedStatement schema = mock(PreparedStatement.class); PreparedStatement lookup = mock(PreparedStatement.class); PreparedStatement claimInsert = mock(PreparedStatement.class); @@ -580,6 +580,7 @@ void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() th 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` = `1Lobby West_Points` + ?")); } } From 2376b64d7ac15b6d84837d40e62deea65811bb7f Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:39:07 -0600 Subject: [PATCH 69/74] Dispatch per-server point hook on entity lane and ensure column --- .../votingplugin/user/SharedPointAdditionJournal.java | 5 +++++ .../com/bencodez/votingplugin/user/VotingPluginUser.java | 2 +- .../user/VotingPluginUserPointSchedulingTest.java | 7 ++++++- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 0449ada51..a0e9cce25 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -15,6 +15,7 @@ import java.util.concurrent.TimeUnit; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; +import com.bencodez.simpleapi.sql.DataType; import com.bencodez.simpleapi.sql.mysql.DbType; /** @@ -240,6 +241,10 @@ AdditionResult settleClaim(String operationId, String uuid, String journalPoints if (!isSafeColumn(journalPointsColumn) || !isSafeColumn(creditPointsColumn)) { throw new SQLException("Unsafe shared point column"); } + // The legacy UserData write created a PerServerPoints column on first use. + // Direct settlement bypasses that path, so create the local integer column + // before opening the atomic credit-and-journal transaction. + if (!journalPointsColumn.equals(creditPointsColumn)) table.checkColumn(creditPointsColumn, DataType.INTEGER); String select = "SELECT " + qi("player_uuid") + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("requested_amount") + ", " + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index a541a35dd..c096f497a 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -391,7 +391,7 @@ private void completePerServerPointAddition(SharedMysqlPointMutator sharedPoints claimOwner, completion); return; } - PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); + PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value, false); hookStarted = true; Bukkit.getPluginManager().callEvent(event); // Capture the local key and listener-adjusted delta while still on the diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 87163c0f0..7c7b592e4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -572,11 +572,16 @@ void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() th 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(any(PlayerReceivePointsEvent.class)); + 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"); From 01adaf394e766f8e89cf678f79bde2b7190d3de9 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 15:43:41 -0600 Subject: [PATCH 70/74] Credit nullable first-use per-server points atomically --- .../votingplugin/user/SharedPointAdditionJournal.java | 7 +++++-- .../votingplugin/user/SharedPointAdditionJournalTest.java | 2 +- .../user/VotingPluginUserPointSchedulingTest.java | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index a0e9cce25..532f01a6b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -250,8 +250,11 @@ AdditionResult settleClaim(String operationId, String uuid, String journalPoints + qi("hook_owner") + ", " + qi("created_at") + " FROM " + qiJournal() + " WHERE " + qi("operation_id") + " = ? FOR UPDATE"; String points = qi(creditPointsColumn); - String updatePoints = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points - + " + ? WHERE " + qi("uuid") + uuidCast(); + // A first-use PerServerPoints column is nullable for existing player rows. + // Coalesce inside the same transaction so completion cannot record a credit + // while NULL arithmetic left the physical balance unchanged. + String updatePoints = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = COALESCE(" + points + + ", 0) + ? WHERE " + qi("uuid") + uuidCast(); String readPoints = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast(); String complete = "UPDATE " + qiJournal() + " SET " + qi("amount") + " = ?, " + qi("state") diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index c0e33bd25..260619de1 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -363,7 +363,7 @@ void perServerSettlementCreditsTheLocalColumnAndCompletesTheSharedClaimAtomicall 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` = `lobby_Points` + ?"))); + "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( diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 7c7b592e4..14c3c66d8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -585,7 +585,7 @@ void perServerPointAdditionRunsOnlyAfterHistoricJournalMissAndBukkitHandoff() th 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` = `1Lobby West_Points` + ?")); + verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.contains("`1Lobby West_Points` = COALESCE(`1Lobby West_Points`, 0) + ?")); } } From c650501f04de856510ef0d4865e01944974a8e15 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:12:58 -0600 Subject: [PATCH 71/74] Serialize per-server MySQL point mutations with journals --- .../user/SharedMysqlPointMutator.java | 30 ++++++++--- .../votingplugin/user/VotingPluginUser.java | 40 +++++++------- .../service/SharedMysqlPurchaseJournal.java | 6 +-- .../service/VoteShopPurchaseService.java | 12 ++++- .../user/SharedMysqlPointMutatorTest.java | 54 +++++++++++++++++-- .../VotingPluginUserPointSchedulingTest.java | 20 ++++++- .../SharedMysqlPurchaseJournalTest.java | 6 +++ 7 files changed, 131 insertions(+), 37 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index db0e26a92..90fc27d10 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -17,6 +17,7 @@ import com.bencodez.advancedcore.api.user.usercache.UserDataCache; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; import com.bencodez.simpleapi.sql.mysql.DbType; +import com.bencodez.simpleapi.sql.DataType; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.simpleapi.folialib.enums.EntityTaskResult; @@ -43,6 +44,16 @@ boolean applies() { return usesSharedMysqlPoints(plugin); } + /** + * Point columns live in the MySQL user row for both shared and PerServerPoints + * deployments. Ordinary point mutations must therefore bypass queued UserData + * writes in either mode; {@link #applies()} remains the narrower shared-points + * check used by cross-server journals and transfers. + */ + boolean usesMysqlPointMutations() { + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()); + } + static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()) && !plugin.getBungeeSettings().isPerServerPoints(); @@ -863,10 +874,10 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat MySQL table = plugin.getMysql(); String points = user.getPointsPath(); StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ") - .append(table.qi(points)).append(" = ").append(table.qi(points)).append(" + ? WHERE ") + .append(table.qi(points)).append(" = COALESCE(").append(table.qi(points)).append(", 0) + ? WHERE ") .append(table.qi("uuid")).append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); if (requireNonnegative) { - sql.append(" AND ").append(table.qi(points)).append(" >= ?"); + sql.append(" AND COALESCE(").append(table.qi(points)).append(", 0) >= ?"); } try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql.toString())) { @@ -896,8 +907,8 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { MySQL table = plugin.getMysql(); String points = user.getPointsPath(); String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); - String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = " - + table.qi(points) + " + ? WHERE " + uuidMatch; + String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = COALESCE(" + + table.qi(points) + ", 0) + ? WHERE " + uuidMatch; String read = "SELECT " + table.qi(points) + " FROM " + table.qi(table.getTableName()) + " WHERE " + uuidMatch; boolean updateCommitted = false; Integer committedTotal = null; @@ -964,8 +975,8 @@ private void capAt(VotingPluginUser user, int maximum) { drainCache(user); MySQL table = plugin.getMysql(); String points = user.getPointsPath(); - String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" - + table.qi(points) + ", ?) WHERE " + table.qi("uuid") + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE(" + + table.qi(points) + ", 0), ?) WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql)) { @@ -983,8 +994,8 @@ private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { drainCache(user); MySQL table = plugin.getMysql(); String points = user.getPointsPath(); - String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(" - + table.qi(points) + " + ?, ?) WHERE " + table.qi("uuid") + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE(" + + table.qi(points) + ", 0) + ?, ?) WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); try (Connection connection = requireConnection(table); PreparedStatement statement = connection.prepareStatement(sql)) { @@ -1004,6 +1015,9 @@ private void drainCache(VotingPluginUser user) { } private void drainCache(VotingPluginUser user, String pointsColumn) { + if (plugin.getBungeeSettings().isPerServerPoints()) { + plugin.getMysql().checkColumn(pointsColumn, DataType.INTEGER); + } SharedMysqlCacheReconciler.withCacheDumpFence(() -> { if (!user.isCached()) return; UserDataCache cache = user.getCache(); diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index c096f497a..5d4fb357b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -194,9 +194,9 @@ public void addOfflineVote(String voteSiteName) { public void addPoints() { int points = plugin.getConfigFile().getPointsOnVote(); SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - boolean sharedMysql = sharedPoints.applies(); + boolean mysqlPoints = sharedPoints.usesMysqlPointMutations(); int limit = plugin.getConfigFile().getLimitVotePoints(); - if (sharedMysql && points != 0 && limit > 0) { + if (mysqlPoints && points != 0 && limit > 0) { // Keep the receive hook semantics of addPoints(int, boolean), while // accepting the addition and upper bound as one persistence task. PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, points); @@ -212,10 +212,10 @@ public void addPoints() { // Vote processing runs on the server lane. Shared MySQL arithmetic must // use the lifecycle persistence executor instead of blocking a tick on // connection acquisition and the committed-balance read. - addPoints(points, sharedMysql); + addPoints(points, mysqlPoints); } if (limit > 0) { - if (sharedMysql) { + if (mysqlPoints) { sharedPoints.cap(this, limit, true); } else if (getPoints() > limit) { setPoints(limit); @@ -261,7 +261,7 @@ public synchronized int addPoints(int value, boolean async) { * arithmetic onto the persistence executor. */ public int addPointsStorageAware(int value) { - return addPoints(value, new SharedMysqlPointMutator(plugin).applies()); + return addPoints(value, new SharedMysqlPointMutator(plugin).usesMysqlPointMutations()); } /** @@ -287,11 +287,11 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu */ public synchronized CompletionStage addPointsStorageAwareAsync(int value, String operationId) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (!sharedPoints.applies()) { - if (operationId != null && !operationId.isEmpty() - && SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) { - return addPerServerPointsWithReplayClaim(sharedPoints, value, operationId); - } + if (!sharedPoints.applies() && operationId != null && !operationId.isEmpty() + && SharedMysqlPointMutator.canRecoverSharedMysqlPointJournals(plugin)) { + return addPerServerPointsWithReplayClaim(sharedPoints, value, operationId); + } + if (!sharedPoints.usesMysqlPointMutations()) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(this, value); Bukkit.getPluginManager().callEvent(event); if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); @@ -299,7 +299,7 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu setPoints(newTotal, false); return CompletableFuture.completedFuture(newTotal); } - if (operationId != null && !operationId.isEmpty()) { + if (sharedPoints.applies() && operationId != null && !operationId.isEmpty()) { return addSharedPointsWithReplayLookup(sharedPoints, value, operationId); } @@ -636,7 +636,7 @@ public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, String batchOperationId, BiConsumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (!sharedPoints.applies()) { + if (!sharedPoints.usesMysqlPointMutations()) { for (VotingPluginUser user : users) { user.userDataFetechMode(UserDataFetchMode.NO_CACHE); user.addPointsStorageAware(value, (success, ignored) -> completion.accept(user, success)); @@ -693,7 +693,7 @@ public static void setPointsStorageAware(VotingPluginMain plugin, List completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (!sharedPoints.applies()) { + if (!sharedPoints.usesMysqlPointMutations()) { setPoints(value); completion.accept(true); return; @@ -756,7 +756,7 @@ private static void bulkSharedMysqlMutation(VotingPluginMain plugin, List completion, SharedPointMutation sharedMutation, OrdinaryPointMutation ordinaryMutation, boolean authoritativeReadRequired) { if (users.isEmpty()) return; - if (!new SharedMysqlPointMutator(plugin).applies()) { + if (!new SharedMysqlPointMutator(plugin).usesMysqlPointMutations()) { for (VotingPluginUser user : users) { if (authoritativeReadRequired) user.userDataFetechMode(UserDataFetchMode.NO_CACHE); ordinaryMutation.apply(user, success -> completion.accept(user, success)); @@ -827,7 +827,7 @@ public synchronized void addPointsStorageAware(int value, BiConsumer= points) { setPoints(getPoints() - points); return true; @@ -1955,7 +1955,7 @@ public boolean removePoints(int points) { */ public boolean removePoints(int points, boolean async) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) return sharedPoints.remove(this, points, async); + if (sharedPoints.usesMysqlPointMutations()) return sharedPoints.remove(this, points, async); if (getPoints() >= points) { setPoints(getPoints() - points, async); return true; @@ -1970,7 +1970,7 @@ public void removePoints(int points, Consumer completion) { void removePointsOutcome(int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (!sharedPoints.applies()) { + if (!sharedPoints.usesMysqlPointMutations()) { completion.accept(removePoints(points) ? SharedMysqlPointMutator.MutationOutcome.CONFIRMED : SharedMysqlPointMutator.MutationOutcome.REJECTED); return; @@ -2301,7 +2301,7 @@ public void setOfflineVotes(ArrayList offlineVotes) { */ public void setPoints(int value) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) { + if (sharedPoints.usesMysqlPointMutations()) { sharedPoints.set(this, value, false); } else { getUserData().setInt(getPointsPath(), value, false); @@ -2316,7 +2316,7 @@ public void setPoints(int value) { */ public void setPoints(int value, boolean async) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) { + if (sharedPoints.usesMysqlPointMutations()) { sharedPoints.set(this, value, async); } else { getUserData().setInt(getPointsPath(), value, false, async); 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 index 28eef10db..c54a44295 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournal.java @@ -126,13 +126,13 @@ boolean reserve(String purchaseId, String uuid, String pointsColumn, String limi + ", " + 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(" - ?"); + .append(points).append(" = COALESCE(").append(points).append(", 0) - ?"); 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(" >= ?"); + debit.append(" WHERE ").append(qi("uuid")).append(uuidCast()).append(" AND COALESCE(").append(points) + .append(", 0) >= ?"); if (limitColumn != null) { debit.append(" AND COALESCE(").append(qi(limitColumn)).append(", 0) < ?"); } 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 f1a3fb179..a5d339ba1 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 @@ -627,17 +627,19 @@ VoteShopPurchaseResult debitSharedMysql(VotingPluginUser user, VoteShopItem item MySQL table = plugin.getMysql(); String pointsColumn = user.getPointsPath(); String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + ensurePerServerPointsColumn(table, pointsColumn); 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(" - ?"); + .append(table.qi(pointsColumn)).append(" = COALESCE(").append(table.qi(pointsColumn)) + .append(", 0) - ?"); 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(" >= ?"); + .append(" AND COALESCE(").append(table.qi(pointsColumn)).append(", 0) >= ?"); if (limitColumn != null) sql.append(" AND COALESCE(").append(table.qi(limitColumn)).append(", 0) < ?"); boolean debited = false; @@ -677,6 +679,7 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo MySQL table = plugin.getMysql(); String pointsColumn = user.getPointsPath(); String limitColumn = item.getLimit() > 0 ? "VoteShopLimit" + item.getIdentifier() : null; + ensurePerServerPointsColumn(table, pointsColumn); drainPurchaseCache(user, pointsColumn); if (limitColumn != null) { table.checkColumn(limitColumn, DataType.INTEGER); @@ -706,6 +709,11 @@ private SharedPurchaseDebit reserveSharedMysqlPurchase(VotingPluginUser user, Vo return new SharedPurchaseDebit(sharedMysqlFailure(user, item, limitColumn), null, null, null, null); } + /** The legacy cache path created server-suffixed points columns before direct SQL used them. */ + private void ensurePerServerPointsColumn(MySQL table, String pointsColumn) { + if (plugin.getBungeeSettings().isPerServerPoints()) table.checkColumn(pointsColumn, DataType.INTEGER); + } + private void drainPurchaseCache(VotingPluginUser user, String pointsColumn) { withSharedMysqlCacheDumpFence(() -> { if (!user.isCached()) return; diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java index 6653187f8..0f7968893 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedMysqlPointMutatorTest.java @@ -35,6 +35,7 @@ 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.DataType; import com.bencodez.simpleapi.sql.data.DataValue; import com.bencodez.simpleapi.sql.data.DataValueInt; import com.bencodez.votingplugin.VotingPluginMain; @@ -566,7 +567,7 @@ void addUsesAtomicDatabaseArithmeticInsteadOfAnAbsoluteCachedWrite() throws Exce 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(0).contains("`Points` = COALESCE(`Points`, 0) + ?")); assertTrue(query.getAllValues().get(1).contains("SELECT `Points`")); verify(statement).setInt(1, 10); verify(statement).executeUpdate(); @@ -663,7 +664,7 @@ void capUsesLeastSoItCannotRestoreAConcurrentDebit() throws Exception { ArgumentCaptor query = ArgumentCaptor.forClass(String.class); verify(connection).prepareStatement(query.capture()); - assertTrue(query.getValue().contains("`Points` = LEAST(`Points`, ?)")); + assertTrue(query.getValue().contains("`Points` = LEAST(COALESCE(`Points`, 0), ?)")); } @Test @@ -710,7 +711,7 @@ void addAndCapUsesOneAtomicPersistenceMutation() throws Exception { ArgumentCaptor query = ArgumentCaptor.forClass(String.class); verify(connection).prepareStatement(query.capture()); - assertTrue(query.getValue().contains("`Points` = LEAST(`Points` + ?, ?)")); + assertTrue(query.getValue().contains("`Points` = LEAST(COALESCE(`Points`, 0) + ?, ?)")); verify(statement).setInt(1, 10); verify(statement).setInt(2, 100); verify(statement).setString(3, "00000000-0000-0000-0000-000000000001"); @@ -718,6 +719,53 @@ void addAndCapUsesOneAtomicPersistenceMutation() throws Exception { assertFalse(values.containsKey("Points")); } + @Test + void perServerMysqlMutationsUsePhysicalColumnWithAtomicArithmetic() 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 add = mock(PreparedStatement.class); + PreparedStatement read = mock(PreparedStatement.class); + PreparedStatement remove = mock(PreparedStatement.class); + PreparedStatement cap = 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(add, read, remove, cap); + when(add.executeUpdate()).thenReturn(1); + when(read.executeQuery()).thenReturn(result); + when(result.next()).thenReturn(true); + when(result.getInt(1)).thenReturn(15); + when(remove.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(true); + when(plugin.getMysql()).thenReturn(table); + VotingPluginUser user = mock(VotingPluginUser.class); + when(user.getUUID()).thenReturn("00000000-0000-0000-0000-000000000001"); + when(user.getPointsPath()).thenReturn("hub_Points"); + + SharedMysqlPointMutator mutator = new SharedMysqlPointMutator(plugin); + assertEquals(15, mutator.add(user, 5, false)); + assertTrue(mutator.remove(user, 3)); + mutator.cap(user, 10, false); + + verify(table, times(3)).checkColumn("hub_Points", DataType.INTEGER); + ArgumentCaptor query = ArgumentCaptor.forClass(String.class); + verify(connection, times(4)).prepareStatement(query.capture()); + assertTrue(query.getAllValues().get(0).contains("`hub_Points` = COALESCE(`hub_Points`, 0) + ?")); + assertTrue(query.getAllValues().stream() + .anyMatch(sqlText -> sqlText.contains("`hub_Points` = COALESCE(`hub_Points`, 0) + ?"))); + assertTrue(query.getAllValues().stream() + .anyMatch(sqlText -> sqlText.contains("COALESCE(`hub_Points`, 0) >= ?"))); + assertTrue(query.getAllValues().stream() + .anyMatch(sqlText -> sqlText.contains("`hub_Points` = LEAST(COALESCE(`hub_Points`, 0), ?)"))); + } + @Test void rejectedAddAndCapSubmissionDiscardsItsPrediction() { VotingPluginMain plugin = mock(VotingPluginMain.class, org.mockito.Mockito.RETURNS_DEEP_STUBS); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 14c3c66d8..615b8201d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -301,7 +301,7 @@ void votePointAwardCombinesSharedAdditionAndCapInOnePersistenceTask() throws Exc assertFalse(values.containsKey("Points")); verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.argThat( - query -> query.contains("`Points` = LEAST(`Points` + ?, ?)"))); + query -> query.contains("`Points` = LEAST(COALESCE(`Points`, 0) + ?, ?)"))); verify(fixture.statement).setInt(1, 5); verify(fixture.statement).setInt(2, 100); } @@ -1002,6 +1002,24 @@ void sharedAbsoluteSetUsesTheDirectMysqlMutator() throws Exception { verify(userData, never()).setInt(anyString(), eq(42), eq(false)); } + @Test + void perServerMysqlAbsoluteSetBypassesQueuedUserDataWrites() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + doReturn("hub_Points").when(fixture.user).getPointsPath(); + UserData userData = mock(UserData.class); + doReturn(userData).when(fixture.user).getUserData(); + + fixture.user.setPoints(42); + + verify(fixture.table).checkColumn("hub_Points", com.bencodez.simpleapi.sql.DataType.INTEGER); + ArgumentCaptor query = ArgumentCaptor.forClass(String.class); + verify(fixture.connection).prepareStatement(query.capture()); + assertTrue(query.getValue().contains("SET `hub_Points` = ?")); + verify(fixture.statement).setInt(1, 42); + verify(userData, never()).setInt(anyString(), eq(42), eq(false)); + } + @Test void sharedPointMutationInvalidatesOnlyPointsFromACacheRecreatedDuringJdbc() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java index b92a48431..7f49f61e8 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/SharedMysqlPurchaseJournalTest.java @@ -60,6 +60,12 @@ void reservationPersistsPendingDebitInTheSameTransaction() throws Exception { verify(insert).setLong(9, 3L); verify(insert).setString(10, "PENDING"); verify(debit).setInt(1, 10); + org.mockito.ArgumentCaptor sql = org.mockito.ArgumentCaptor.forClass(String.class); + verify(fixture.work, org.mockito.Mockito.times(4)).prepareStatement(sql.capture()); + String conditionalDebit = sql.getAllValues().get(3); + assertTrue(conditionalDebit.contains("`Points` = COALESCE(`Points`, 0) - ?")); + assertTrue(conditionalDebit.contains("COALESCE(`Points`, 0) >= ?")); + assertTrue(conditionalDebit.contains("COALESCE(`VoteShopLimitdaily`, 0) < ?")); verify(fixture.work).commit(); } From 765f2e7e0eec80b6788f922f6354c50f6a413865 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:23:58 -0600 Subject: [PATCH 72/74] Confirm all MySQL shop debits and atomic per-server credits --- .../user/SharedPointAdditionJournal.java | 5 ++-- .../votingplugin/user/VotingPluginUser.java | 2 +- .../service/VoteShopPurchaseService.java | 11 ++++---- .../user/SharedPointAdditionJournalTest.java | 16 +++++++++++ .../VotingPluginUserPointSchedulingTest.java | 27 +++++++++++++++++++ .../service/VoteShopPurchaseServiceTest.java | 7 ++--- 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java index 532f01a6b..4b6f462c3 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointAdditionJournal.java @@ -98,9 +98,10 @@ private AdditionResult mutate(String operationId, String uuid, String pointsColu + ", " + qi("points_column") + ", " + qi("amount") + ", " + qi("requested_amount") + ", " + qi("state") + ", " + qi("total_points") + ", " + qi("created_at") + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?)"; String points = qi(pointsColumn); - String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points + String coalescedPoints = "COALESCE(" + points + ", 0)"; + String update = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + coalescedPoints + " + ? WHERE " + qi("uuid") + uuidCast() - + (requireNonnegative ? " AND " + points + " >= ?" : ""); + + (requireNonnegative ? " AND " + coalescedPoints + " >= ?" : ""); String read = "SELECT " + points + " FROM " + qi(table.getTableName()) + " WHERE " + qi("uuid") + uuidCast(); String complete = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("total_points") + " = ? WHERE " + qi("operation_id") + " = ?"; diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 5d4fb357b..4b718015b 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -248,7 +248,7 @@ public synchronized int addPoints(int value, boolean async) { return getPoints(); } SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) { + if (sharedPoints.usesMysqlPointMutations()) { return sharedPoints.add(this, event.getPoints(), async); } int newTotal = getPoints() + event.getPoints(); 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 a5d339ba1..d0d34a56b 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 @@ -510,14 +510,13 @@ private boolean usesSharedMysqlPoints() { } /** - * 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. + * Every MySQL purchase must reserve its selected points column through the + * durable journal. Per-server balances still share a MySQL row with ordinary + * point writers, so queuing a legacy debit before the reward can acknowledge a + * purchase that has not committed and can be overwritten by a concurrent write. */ private boolean usesMysqlPurchaseReservation(VoteShopItem item) { - return usesSharedMysqlPoints() || plugin != null && item != null && item.getLimit() > 0 - && UserStorage.MYSQL.equals(plugin.getStorageType()); + return plugin != null && UserStorage.MYSQL.equals(plugin.getStorageType()); } private static boolean usesSharedMysqlPoints(VotingPluginMain plugin) { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java index 260619de1..86156a858 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointAdditionJournalTest.java @@ -101,6 +101,22 @@ void lostCommitAcknowledgementAndFailedConfirmationRetryCreditsExactlyOnce() thr verify(retryLookup).setString(1, "reward-operation"); } + @Test + void firstPerServerColumnCreditTreatsNullAsZero() throws Exception { + Fixture fixture = fixture(); + Connection missing = missingLookup(); + Attempt credit = successfulAttempt(5); + when(fixture.sql.getConnectionManager().getConnection()).thenReturn(missing, credit.connection()); + + assertEquals(5, new SharedPointAdditionJournal(fixture.table, false) + .add("bulk-add", "player", "hub_Points", 5, 100L).total()); + + org.mockito.ArgumentCaptor statements = org.mockito.ArgumentCaptor.forClass(String.class); + verify(credit.connection(), times(4)).prepareStatement(statements.capture()); + assertTrue(statements.getAllValues().get(1) + .contains("SET `hub_Points` = COALESCE(`hub_Points`, 0) + ?")); + } + @Test void completedOperationRejectsAConflictingRetryInsteadOfChangingPoints() throws Exception { Fixture fixture = fixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index 615b8201d..be9eea81d 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -378,6 +378,33 @@ void sharedAddReturnsTheCommittedDatabaseBalanceInsteadOfAPredictedWrapperTotal( verify(data, never()).getInt("Points", UserDataFetchMode.NO_CACHE); } + @Test + void perServerMysqlAddUsesTheAtomicDeltaInsteadOfALegacyAbsoluteWrite() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + doReturn("hub_Points").when(fixture.user).getPointsPath(); + PreparedStatement read = mock(PreparedStatement.class); + ResultSet result = mock(ResultSet.class); + 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(15); + UserData userData = mock(UserData.class); + doReturn(userData).when(fixture.user).getUserData(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + assertEquals(15, fixture.user.addPoints(5)); + } + + verify(fixture.table).checkColumn("hub_Points", com.bencodez.simpleapi.sql.DataType.INTEGER); + ArgumentCaptor update = ArgumentCaptor.forClass(String.class); + verify(fixture.connection, org.mockito.Mockito.times(2)).prepareStatement(update.capture()); + assertTrue(update.getAllValues().get(0).contains("SET `hub_Points` = COALESCE(`hub_Points`, 0) + ?")); + verify(userData, never()).setInt(anyString(), anyInt(), org.mockito.ArgumentMatchers.anyBoolean()); + } + @Test void storageAwareAddReportsOnlyAfterCommittedSharedWrite() throws Exception { PointFixture fixture = pointFixture(); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java index 18fd5964a..58dcc53a0 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/voteshop/service/VoteShopPurchaseServiceTest.java @@ -1111,7 +1111,7 @@ void sharedMysqlFailureReleasesDebitConnectionBeforeClassifyingTheLimit() throws } @Test - void perServerMysqlLimitedPurchaseQueuesJournalReservationOffCallingThread() throws Exception { + void perServerMysqlUnlimitedPurchaseQueuesJournalReservationOffCallingThread() 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); @@ -1127,14 +1127,15 @@ void perServerMysqlLimitedPurchaseQueuesJournalReservationOffCallingThread() thr when(user.getPoints()).thenReturn(0); VoteShopItem item = mock(VoteShopItem.class); when(item.getCost()).thenReturn(10); - when(item.getLimit()).thenReturn(1); - when(user.getVoteShopIdentifierLimit(anyString())).thenReturn(1); + when(item.getLimit()).thenReturn(0); new VoteShopPurchaseService(plugin, definition).purchase(mock(org.bukkit.entity.Player.class), user, item, result -> { }); verify(persistenceExecutor).execute(any(Runnable.class)); verify(sql.getConnectionManager(), never()).getConnection(); + verify(user, never()).removePoints(org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.anyBoolean()); } @Test From 8498622ffcaa9b16e984be4d98d702ecc627124c Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:38:16 -0600 Subject: [PATCH 73/74] Keep MySQL transfers off server lanes and pin queued point columns --- .../user/SharedMysqlPointMutator.java | 135 ++++++++++++------ .../votingplugin/user/VotingPluginUser.java | 40 ++++-- .../VotingPluginUserPointSchedulingTest.java | 71 +++++++++ 3 files changed, 195 insertions(+), 51 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java index 90fc27d10..0565655fc 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedMysqlPointMutator.java @@ -117,11 +117,12 @@ static void recoverTransfers(VotingPluginMain plugin, SharedPointTransferJournal int add(VotingPluginUser user, int amount, boolean async) { if (async) { - int previousTotal = cachedPoints(user); + String pointsColumn = user.getPointsPath(); + int previousTotal = cachedPoints(user, pointsColumn); int predictedTotal = previousTotal + amount; - cachePredictedPoints(user, predictedTotal); - if (!run(() -> update(user, amount, false), true)) { - discardOptimisticPoints(user); + cachePredictedPoints(user, predictedTotal, pointsColumn); + if (!run(() -> update(user, amount, false, pointsColumn), true)) { + discardOptimisticPoints(user, pointsColumn); return previousTotal; } // The mutation has not happened yet, so the historical asynchronous API @@ -132,14 +133,18 @@ int add(VotingPluginUser user, int amount, boolean async) { } private void cachePredictedPoints(VotingPluginUser user, int predictedTotal) { + cachePredictedPoints(user, predictedTotal, user.getPointsPath()); + } + + private void cachePredictedPoints(VotingPluginUser user, int predictedTotal, String pointsColumn) { UserDataCache cache = user.getCache(); if (cache == null) return; synchronized (cache) { var values = cache.getCache(); if (values == null) return; DataValue prediction = new DataValueInt(predictedTotal); - values.put(user.getPointsPath(), prediction); - SharedMysqlCacheReconciler.recordOptimisticPoint(cache, user.getPointsPath(), prediction); + values.put(pointsColumn, prediction); + SharedMysqlCacheReconciler.recordOptimisticPoint(cache, pointsColumn, prediction); } } @@ -150,10 +155,14 @@ private void cachePredictedPoints(VotingPluginUser user, int predictedTotal) { * the prediction while the async operation waited in the executor. */ private void discardOptimisticPoints(VotingPluginUser user) { + discardOptimisticPoints(user, user.getPointsPath()); + } + + private void discardOptimisticPoints(VotingPluginUser user, String pointsColumn) { UserDataCache cache = user.getCache(); if (cache == null) return; synchronized (cache) { - SharedMysqlCacheReconciler.discardOptimisticPoint(cache, user.getPointsPath()); + SharedMysqlCacheReconciler.discardOptimisticPoint(cache, pointsColumn); } } @@ -167,34 +176,46 @@ AddResult addCommitted(VotingPluginUser user, int amount) { * next invocation without applying the credit again. */ AddResult addCommitted(VotingPluginUser user, int amount, String operationId) { - if (operationId == null || operationId.isEmpty()) return addCommitted(user, amount); - drainCache(user); + return addCommittedToColumn(user, amount, operationId, user.getPointsPath()); + } + + AddResult addCommittedToColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) { + if (operationId == null || operationId.isEmpty()) return addCommittedToColumn(user, amount, pointsColumn); + drainCache(user, pointsColumn); try { SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) - .add(operationId, user.getUUID(), user.getPointsPath(), amount, System.currentTimeMillis()); + .add(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis()); return new AddResult(true, result.total()); } catch (SQLException failure) { logFailure(failure); return new AddResult(MutationOutcome.INDETERMINATE, 0); } finally { - discardPointsCache(user); + discardPointsCache(user, pointsColumn); } } + AddResult addCommittedToColumn(VotingPluginUser user, int amount, String pointsColumn) { + return addAndReadCommittedResult(user, amount, pointsColumn); + } + /** Durable, confirmable conditional debit for administrative and purchase retries. */ AddResult removeCommitted(VotingPluginUser user, int amount, String operationId) { + return removeCommittedFromColumn(user, amount, operationId, user.getPointsPath()); + } + + AddResult removeCommittedFromColumn(VotingPluginUser user, int amount, String operationId, String pointsColumn) { if (operationId == null || operationId.isEmpty()) return new AddResult(false, 0); - drainCache(user); + drainCache(user, pointsColumn); try { SharedPointAdditionJournal.AdditionResult result = SharedPointAdditionJournal.forTable(plugin.getMysql()) - .subtract(operationId, user.getUUID(), user.getPointsPath(), amount, System.currentTimeMillis()); + .subtract(operationId, user.getUUID(), pointsColumn, amount, System.currentTimeMillis()); return new AddResult(true, result.total()); } catch (SQLException failure) { logFailure(failure); return new AddResult(failure instanceof SharedPointAdditionJournal.DebitRejectedException ? MutationOutcome.REJECTED : MutationOutcome.INDETERMINATE, 0); } finally { - discardPointsCache(user); + discardPointsCache(user, pointsColumn); } } @@ -300,15 +321,21 @@ void acknowledgePointAdditionNow(String operationId) { } void set(VotingPluginUser user, int value, boolean async) { - run(() -> setAbsolute(user, value), async); + String pointsColumn = user.getPointsPath(); + run(() -> setAbsolute(user, value, pointsColumn), async); } boolean setCommitted(VotingPluginUser user, int value) { return setAbsolute(user, value); } + boolean setCommittedInColumn(VotingPluginUser user, int value, String pointsColumn) { + return setAbsolute(user, value, pointsColumn); + } + void cap(VotingPluginUser user, int maximum, boolean async) { - run(() -> capAt(user, maximum), async); + String pointsColumn = user.getPointsPath(); + run(() -> capAt(user, maximum, pointsColumn), async); } /** @@ -317,16 +344,17 @@ void cap(VotingPluginUser user, int maximum, boolean async) { * the addition while dropping a separately submitted cap. */ void addAndCap(VotingPluginUser user, int amount, int maximum, boolean async) { + String pointsColumn = user.getPointsPath(); if (!async) { - addAndCapAt(user, amount, maximum); + addAndCapAt(user, amount, maximum, pointsColumn); return; } - int previousTotal = cachedPoints(user); + int previousTotal = cachedPoints(user, pointsColumn); int predictedTotal = (int) Math.max(Integer.MIN_VALUE, Math.min((long) previousTotal + amount, maximum)); - cachePredictedPoints(user, predictedTotal); - if (!run(() -> addAndCapAt(user, amount, maximum), true)) { - discardOptimisticPoints(user); + cachePredictedPoints(user, predictedTotal, pointsColumn); + if (!run(() -> addAndCapAt(user, amount, maximum, pointsColumn), true)) { + discardOptimisticPoints(user, pointsColumn); } } @@ -336,15 +364,19 @@ boolean remove(VotingPluginUser user, int amount) { boolean remove(VotingPluginUser user, int amount, boolean async) { if (!async) return remove(user, amount); - boolean predictedSuccess = cachedPoints(user) >= amount; - boolean submitted = run(() -> update(user, -amount, true), true); + String pointsColumn = user.getPointsPath(); + boolean predictedSuccess = cachedPoints(user, pointsColumn) >= amount; + boolean submitted = run(() -> update(user, -amount, true, pointsColumn), true); // Preserve the historical asynchronous API contract: the caller receives // the cached prediction while the conditional database debit runs later. return submitted && predictedSuccess; } private int cachedPoints(VotingPluginUser user) { - String path = user.getPointsPath(); + return cachedPoints(user, user.getPointsPath()); + } + + private int cachedPoints(VotingPluginUser user, String path) { UserDataCache cache = user.getCache(); if (cache != null) { synchronized (cache) { @@ -460,6 +492,8 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target org.bukkit.entity.Player sourcePlayer = source.getPlayer(); org.bukkit.entity.Player targetPlayer = target.getPlayer(); org.bukkit.entity.Player approvalPlayer = targetPlayer != null ? targetPlayer : sourcePlayer; + String sourcePoints = source.getPointsPath(); + String targetPoints = target.getPointsPath(); try { plugin.getTimer().execute(() -> { try { @@ -471,8 +505,6 @@ void transferWithBukkitApproval(VotingPluginUser source, VotingPluginUser target return; } MySQL table = plugin.getMysql(); - String sourcePoints = source.getPointsPath(); - String targetPoints = target.getPointsPath(); String transferId = UUID.randomUUID().toString(); String owner = UUID.randomUUID().toString(); SharedPointTransferJournal journal; @@ -870,9 +902,12 @@ private boolean run(Runnable operation, boolean async) { } private boolean update(VotingPluginUser user, int delta, boolean requireNonnegative) { - drainCache(user); + return update(user, delta, requireNonnegative, user.getPointsPath()); + } + + private boolean update(VotingPluginUser user, int delta, boolean requireNonnegative, String points) { + drainCache(user, points); MySQL table = plugin.getMysql(); - String points = user.getPointsPath(); StringBuilder sql = new StringBuilder("UPDATE ").append(table.qi(table.getTableName())).append(" SET ") .append(table.qi(points)).append(" = COALESCE(").append(table.qi(points)).append(", 0) + ? WHERE ") .append(table.qi("uuid")).append(table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); @@ -889,7 +924,7 @@ private boolean update(VotingPluginUser user, int delta, boolean requireNonnegat logFailure(failure); return false; } finally { - discardPointsCache(user); + discardPointsCache(user, points); } } @@ -903,9 +938,12 @@ private int addAndReadCommitted(VotingPluginUser user, int amount) { } private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { - drainCache(user); + return addAndReadCommittedResult(user, amount, user.getPointsPath()); + } + + private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount, String points) { + drainCache(user, points); MySQL table = plugin.getMysql(); - String points = user.getPointsPath(); String uuidMatch = table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); String update = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = COALESCE(" + table.qi(points) + ", 0) + ? WHERE " + uuidMatch; @@ -930,7 +968,7 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { } catch (SQLException failure) { logFailure(failure); } finally { - discardPointsCache(user); + discardPointsCache(user, points); } // Do not evaluate the fallback while the JDBC handle is still held. With a // one-connection pool, getPoints() may need that same handle after a missing @@ -940,6 +978,9 @@ private AddResult addAndReadCommittedResult(VotingPluginUser user, int amount) { // mutation failure without checking out a second connection and let callers // complete their callback deterministically. if (!updateCommitted) return new AddResult(false, 0); + // A live mode reload can change getPointsPath() while this queued write is + // running. Keep the fallback on the column that received the credit. + if (!points.equals(user.getPointsPath())) return new AddResult(true, user.getUserData().getInt(points)); return new AddResult(true, user.getPoints()); } @@ -953,9 +994,13 @@ record AddResult(MutationOutcome outcome, int total) { } private boolean setAbsolute(VotingPluginUser user, int value) { - drainCache(user); + return setAbsolute(user, value, user.getPointsPath()); + } + + private boolean setAbsolute(VotingPluginUser user, int value, String points) { + drainCache(user, points); MySQL table = plugin.getMysql(); - String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(user.getPointsPath()) + String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = ? WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); try (Connection connection = requireConnection(table); @@ -967,14 +1012,17 @@ private boolean setAbsolute(VotingPluginUser user, int value) { logFailure(failure); return false; } finally { - discardPointsCache(user); + discardPointsCache(user, points); } } private void capAt(VotingPluginUser user, int maximum) { - drainCache(user); + capAt(user, maximum, user.getPointsPath()); + } + + private void capAt(VotingPluginUser user, int maximum, String points) { + drainCache(user, points); MySQL table = plugin.getMysql(); - String points = user.getPointsPath(); String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE(" + table.qi(points) + ", 0), ?) WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); @@ -986,14 +1034,17 @@ private void capAt(VotingPluginUser user, int maximum) { } catch (SQLException failure) { logFailure(failure); } finally { - discardPointsCache(user); + discardPointsCache(user, points); } } private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { - drainCache(user); + addAndCapAt(user, amount, maximum, user.getPointsPath()); + } + + private void addAndCapAt(VotingPluginUser user, int amount, int maximum, String points) { + drainCache(user, points); MySQL table = plugin.getMysql(); - String points = user.getPointsPath(); String sql = "UPDATE " + table.qi(table.getTableName()) + " SET " + table.qi(points) + " = LEAST(COALESCE(" + table.qi(points) + ", 0) + ?, ?) WHERE " + table.qi("uuid") + (table.getDbType() == DbType.POSTGRESQL ? " = ?::uuid" : " = ?"); @@ -1006,7 +1057,7 @@ private void addAndCapAt(VotingPluginUser user, int amount, int maximum) { } catch (SQLException failure) { logFailure(failure); } finally { - discardPointsCache(user); + discardPointsCache(user, points); } } @@ -1015,7 +1066,7 @@ private void drainCache(VotingPluginUser user) { } private void drainCache(VotingPluginUser user, String pointsColumn) { - if (plugin.getBungeeSettings().isPerServerPoints()) { + if (!"Points".equals(pointsColumn)) { plugin.getMysql().checkColumn(pointsColumn, DataType.INTEGER); } SharedMysqlCacheReconciler.withCacheDumpFence(() -> { diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java index 4b718015b..48aff1f0f 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/VotingPluginUser.java @@ -308,10 +308,12 @@ public synchronized CompletionStage addPointsStorageAwareAsync(int valu if (event.isCancelled()) return CompletableFuture.completedFuture(getPoints()); CompletableFuture completion = new CompletableFuture<>(); + String pointsColumn = getPointsPath(); try { plugin.getTimer().execute(() -> { try { - SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints(), operationId); + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommittedToColumn(this, + event.getPoints(), operationId, pointsColumn); if (result.success()) completion.complete(result.total()); else completion.completeExceptionally( new IllegalStateException("Unable to persist shared MySQL points")); @@ -644,10 +646,14 @@ public static void addPointsStorageAware(VotingPluginMain plugin, List eventAmounts = new java.util.IdentityHashMap<>(); + java.util.IdentityHashMap pointColumns = new java.util.IdentityHashMap<>(); for (VotingPluginUser user : users) { PlayerReceivePointsEvent event = new PlayerReceivePointsEvent(user, value); Bukkit.getPluginManager().callEvent(event); - if (!event.isCancelled()) eventAmounts.put(user, event.getPoints()); + if (!event.isCancelled()) { + eventAmounts.put(user, event.getPoints()); + pointColumns.put(user, user.getPointsPath()); + } } bulkSharedMysqlMutation(plugin, users, completion, (mutator, user) -> { @@ -658,7 +664,8 @@ public static void addPointsStorageAware(VotingPluginMain plugin, List users, int value, BiConsumer completion) { + java.util.IdentityHashMap pointColumns = capturePointColumns(users); bulkSharedMysqlMutation(plugin, users, completion, - (mutator, user) -> mutator.setCommitted(user, value), + (mutator, user) -> mutator.setCommittedInColumn(user, value, pointColumns.get(user)), (user, done) -> { user.setPoints(value); done.accept(true); @@ -699,9 +707,10 @@ public void setPointsStorageAware(int value, Consumer completion) { return; } Player player = getPlayer(); + String pointsColumn = getPointsPath(); try { plugin.getTimer().execute(() -> { - boolean updated = sharedPoints.setCommitted(this, value); + boolean updated = sharedPoints.setCommittedInColumn(this, value, pointsColumn); BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(updated)); }); } catch (RuntimeException rejected) { @@ -726,16 +735,25 @@ public static void removePointsStorageAware(VotingPluginMain plugin, List users, int value, String batchOperationId, BiConsumer completion) { + java.util.IdentityHashMap pointColumns = capturePointColumns(users); bulkSharedMysqlMutation(plugin, users, completion, (mutator, user) -> { String operationId = bulkPointOperationId("admin-bulk-remove/", batchOperationId, user.getUUID()); - boolean success = mutator.removeCommitted(user, value, operationId).success(); + boolean success = mutator.removeCommittedFromColumn(user, value, operationId, + pointColumns.get(user)).success(); if (success) mutator.acknowledgePointAdditionNow(operationId); return success; }, (user, done) -> user.removePoints(value, done), true); } + private static java.util.IdentityHashMap capturePointColumns( + List users) { + java.util.IdentityHashMap pointColumns = new java.util.IdentityHashMap<>(); + for (VotingPluginUser user : users) pointColumns.put(user, user.getPointsPath()); + return pointColumns; + } + static String bulkPointOperationId(String prefix, String batchOperationId, String userId) { UUID digest = UUID.nameUUIDFromBytes((batchOperationId + "/" + userId) .getBytes(java.nio.charset.StandardCharsets.UTF_8)); @@ -834,9 +852,11 @@ public synchronized void addPointsStorageAware(int value, BiConsumer { - SharedMysqlPointMutator.AddResult result = sharedPoints.addCommitted(this, event.getPoints()); + SharedMysqlPointMutator.AddResult result = sharedPoints.addCommittedToColumn(this, + event.getPoints(), pointsColumn); BukkitCompletionScheduler.run(plugin, player, () -> completion.accept(result.success(), result.total())); }); @@ -1976,10 +1996,12 @@ void removePointsOutcome(int points, Consumer { - SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommitted(this, points, operationId); + SharedMysqlPointMutator.AddResult result = sharedPoints.removeCommittedFromColumn(this, + points, operationId, pointsColumn); if (result.outcome() == SharedMysqlPointMutator.MutationOutcome.CONFIRMED) { sharedPoints.acknowledgePointAdditionNow(operationId); } @@ -2018,7 +2040,7 @@ static boolean completesLegacyTransfer(PointTransferResult result) { */ public void transferPointsWithResult(VotingPluginUser target, int points, Consumer completion) { SharedMysqlPointMutator sharedPoints = new SharedMysqlPointMutator(plugin); - if (sharedPoints.applies()) { + if (sharedPoints.usesMysqlPointMutations()) { sharedPoints.transferWithBukkitApproval(this, target, points, ignored -> { PlayerReceivePointsEvent receiveEvent = new PlayerReceivePointsEvent(target, points, false); Bukkit.getPluginManager().callEvent(receiveEvent); diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java index be9eea81d..49ae642c4 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/VotingPluginUserPointSchedulingTest.java @@ -253,6 +253,77 @@ void nonSharedTransferCreditsBeforeReportingSuccess() throws Exception { assertEquals(Boolean.TRUE, result.get()); } + @Test + void perServerMysqlTransferQueuesPersistenceBeforeAnyDatabaseOrPointWrite() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + VotingPluginUser target = mock(VotingPluginUser.class); + AtomicReference result = new AtomicReference<>(); + + fixture.user.transferPointsWithResult(target, 10, result::set); + + verify(fixture.persistence).execute(any(Runnable.class)); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + verify(fixture.user, never()).removePoints(10); + verify(target, never()).addPoints(10); + assertEquals(null, result.get()); + } + + @Test + void queuedPerServerPointCreditKeepsItsAdmissionColumnAfterModeReload() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + java.util.concurrent.atomic.AtomicReference currentColumn = + new java.util.concurrent.atomic.AtomicReference<>("hub_Points"); + doAnswer(ignored -> currentColumn.get()).when(fixture.user).getPointsPath(); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + fixture.user.addPointsStorageAware(5, (success, total) -> { }); + } + ArgumentCaptor queued = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(queued.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + + currentColumn.set("Points"); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + queued.getValue().run(); + + verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.argThat( + query -> query.contains("`hub_Points` = COALESCE(`hub_Points`, 0) + ?"))); + verify(fixture.connection, never()).prepareStatement(org.mockito.ArgumentMatchers.argThat( + query -> query.contains("`Points` = COALESCE(`Points`, 0) + ?"))); + } + + @Test + void queuedVotePointCreditKeepsItsAdmissionColumnAfterModeReload() throws Exception { + PointFixture fixture = pointFixture(); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(true); + java.util.concurrent.atomic.AtomicReference currentColumn = + new java.util.concurrent.atomic.AtomicReference<>("hub_Points"); + doAnswer(ignored -> currentColumn.get()).when(fixture.user).getPointsPath(); + UserData data = mock(UserData.class); + doReturn(data).when(fixture.user).getUserData(); + when(data.getInt("hub_Points", UserDataFetchMode.TEMP_ONLY)).thenReturn(10); + + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getPluginManager).thenReturn(mock(PluginManager.class)); + assertEquals(15, fixture.user.addPointsStorageAware(5)); + } + ArgumentCaptor queued = ArgumentCaptor.forClass(Runnable.class); + verify(fixture.persistence).execute(queued.capture()); + verify(fixture.sql.getConnectionManager(), never()).getConnection(); + + currentColumn.set("Points"); + when(fixture.plugin.getBungeeSettings().isPerServerPoints()).thenReturn(false); + queued.getValue().run(); + + verify(fixture.connection).prepareStatement(org.mockito.ArgumentMatchers.argThat( + query -> query.contains("`hub_Points` = COALESCE(`hub_Points`, 0) + ?"))); + verify(fixture.connection, never()).prepareStatement(org.mockito.ArgumentMatchers.argThat( + query -> query.contains("`Points` = COALESCE(`Points`, 0) + ?"))); + } + @Test void votePointAwardQueuesSharedMysqlMutationOffTheServerLane() throws Exception { PointFixture fixture = pointFixture(); From d5811f2ab3743113557b40abd765b1179bc82270 Mon Sep 17 00:00:00 2001 From: BenCodez <17074231+BenCodez@users.noreply.github.com> Date: Sat, 19 Sep 2026 16:46:41 -0600 Subject: [PATCH 74/74] Settle nullable transfer credits and recover quoted point columns --- .../votingplugin/user/SharedPointTransferJournal.java | 9 ++++++--- .../user/SharedPointTransferJournalTest.java | 9 +++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java index f428bcf70..c2f7281ee 100644 --- a/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java +++ b/VotingPlugin/src/main/java/com/bencodez/votingplugin/user/SharedPointTransferJournal.java @@ -416,8 +416,8 @@ private SettlementOutcome settleOnce(String transferId, String owner, String sou String select = "SELECT " + qi("state") + ", " + qi("hook_owner") + " FROM " + qiJournal() + " WHERE " + qi("transfer_id") + " = ? FOR UPDATE"; String points = qi(adjustedCreditPoints == null ? sourcePointsColumn : targetPointsColumn); - String credit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = " + points - + " + ? WHERE " + qi("uuid") + uuidCast(); + String credit = "UPDATE " + qi(table.getTableName()) + " SET " + points + " = COALESCE(" + points + + ", 0) + ? WHERE " + qi("uuid") + uuidCast(); String updateJournal = "UPDATE " + qiJournal() + " SET " + qi("state") + " = ?, " + qi("adjusted_credit_points") + " = ? WHERE " + qi("transfer_id") + " = ?"; try (Connection connection = connection()) { @@ -602,7 +602,10 @@ private void cleanupTerminalRows(long cutoff, int limit) throws SQLException { } private static boolean isSafeColumn(String column) { - return column != null && column.matches("[A-Za-z][A-Za-z0-9_]{0,127}"); + // AbstractSqlTable.qi escapes the dialect delimiter. Existing PerServerPoints + // names may begin with a digit or contain spaces; bound the value and reject + // only the NUL character before quoting it. + return column != null && !column.isEmpty() && column.length() <= 128 && column.indexOf('\0') < 0; } private TransferRow find(String transferId) throws SQLException { diff --git a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java index a9ecefd55..c4280e753 100644 --- a/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java +++ b/VotingPlugin/src/test/java/com/bencodez/votingplugin/user/SharedPointTransferJournalTest.java @@ -18,6 +18,7 @@ import java.sql.ResultSet; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import com.bencodez.advancedcore.api.user.userstorage.mysql.MySQL; @@ -166,6 +167,10 @@ void acceptedHookCreditsAdjustedAmountAndMarksTerminalState() throws Exception { verify(journalUpdate).setString(1, "COMPLETED"); verify(journalUpdate).setInt(2, 4); verify(fixture.lookup).commit(); + ArgumentCaptor sql = ArgumentCaptor.forClass(String.class); + verify(fixture.lookup, times(3)).prepareStatement(sql.capture()); + assertTrue(sql.getAllValues().stream().anyMatch(statement -> + statement.contains("`Points` = COALESCE(`Points`, 0) + ?"))); } @Test @@ -303,7 +308,7 @@ void recoveryRefundsAnExpiredReservationUsingItsPersistedSourceColumn() throws E PreparedStatement cleanupSelect = mock(PreparedStatement.class); PreparedStatement cleanupDelete = mock(PreparedStatement.class); ResultSet expiredReservation = ids("expired-reservation"); - ResultSet reservedRecovery = recoveryRow("RESERVED", 1L, "source", "Points", 10); + ResultSet reservedRecovery = recoveryRow("RESERVED", 1L, "source", "9 Server Points", 10); ResultSet noCleanupCandidates = ids(); when(reservedCandidates.prepareStatement(anyString())).thenReturn(reservedCandidateQuery); when(reservedCandidateQuery.executeQuery()).thenReturn(expiredReservation); @@ -325,7 +330,7 @@ void recoveryRefundsAnExpiredReservationUsingItsPersistedSourceColumn() throws E verify(recovery).commit(); assertEquals(1, refunded.size()); assertEquals("source", refunded.get(0).uuid()); - assertEquals("Points", refunded.get(0).pointsColumn()); + assertEquals("9 Server Points", refunded.get(0).pointsColumn()); } @Test