diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java index 7123df1eb..69da17a1d 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/user/usercache/UserDataManager.java @@ -74,6 +74,24 @@ public class UserDataManager { private final Set sharedCachePopulations = ConcurrentHashMap.newKeySet(); private final Set completedSharedCachePopulations = ConcurrentHashMap.newKeySet(); private final ConcurrentHashMap sharedCachePopulationStates = new ConcurrentHashMap<>(); + /** Live storage identities, maintained from platform join/quit events. */ + // Generation-stamped offline tombstones prevent an older queued platform + // snapshot from erasing a newer quit and letting a later stale snapshot + // resurrect that session. + private final ConcurrentHashMap onlineUserSessions = new ConcurrentHashMap<>(); + private final AtomicLong onlineSessionGeneration = new AtomicLong(); + private final Object[] onlineSessionLocks = createOnlineSessionLocks(); + private record OnlineSessionState(boolean online, long generation) {} + + private static Object[] createOnlineSessionLocks() { + Object[] locks = new Object[64]; + java.util.Arrays.setAll(locks, ignored -> new Object()); + return locks; + } + + private Object onlineSessionLock(UUID uuid) { + return onlineSessionLocks[(uuid.hashCode() & Integer.MAX_VALUE) % onlineSessionLocks.length]; + } private static final class SharedCachePopulationState { private long generation; @@ -1162,23 +1180,164 @@ public void clearCacheBasic() { if (plugin.getStorageType().equals(UserStorage.MYSQL)) plugin.getMysql().clearCacheBasic(); } + /** + * Capture Bukkit-owned online-player state on the platform scheduler, then do + * cache/storage eviction on the user-storage worker. + */ public void clearNonNeededCachedUsers() { + Runnable capture = () -> { + java.util.HashSet platformOnline = new java.util.HashSet<>(); + if (Bukkit.getServer() != null) { + for (Player player : Bukkit.getOnlinePlayers()) { + UUID storageUuid = onlineStorageUuid(player); + if (storageUuid != null) platformOnline.add(storageUuid); + } + } + long snapshotGeneration = onlineSessionGeneration.get(); + try { + timer.execute(() -> { + try { clearNonNeededCachedUsers(reconcileOnlineSnapshot(platformOnline, snapshotGeneration)); } + catch (RuntimeException | Error failure) { + reportDeferredStorageFailure(failure); + throw failure; + } + }); + } catch (RejectedExecutionException rejected) { + if (plugin != null && plugin.isEnabled()) reportDeferredStorageFailure(rejected); + } + }; + if (Bukkit.getServer() == null) capture.run(); + else { + try { plugin.getBukkitScheduler().runTask(plugin, capture); } + catch (RuntimeException failure) { + if (plugin != null && plugin.isEnabled()) reportDeferredStorageFailure(failure); + } + } + } + + private Set reconcileOnlineSnapshot(Set platformOnline, long snapshotGeneration) { + java.util.HashSet online = new java.util.HashSet<>(); + for (UUID uuid : platformOnline) { + synchronized (onlineSessionLock(uuid)) { + OnlineSessionState state = onlineUserSessions.compute(uuid, + (ignored, current) -> current == null + ? new OnlineSessionState(true, snapshotGeneration) : current); + if (state.online()) online.add(uuid); + } + } + for (UUID uuid : Set.copyOf(onlineUserSessions.keySet())) { + synchronized (onlineSessionLock(uuid)) { + OnlineSessionState state = onlineUserSessions.get(uuid); + if (state != null && !state.online() && state.generation() <= snapshotGeneration + && !platformOnline.contains(uuid)) { + onlineUserSessions.remove(uuid, state); + } + } + } + return online; + } + + private UUID onlineStorageUuid(Player player) { + if (player == null) return null; + if (plugin.getOptions().isOnlineMode()) return player.getUniqueId(); + try { + String resolved = UuidLookup.getInstance().getUUID(player.getName()); + return resolved == null || resolved.isEmpty() ? null : UUID.fromString(resolved); + } catch (RuntimeException failure) { + plugin.debug(failure); + return null; + } + } + + public void markUserOnline(UUID uuid) { + if (uuid != null) synchronized (onlineSessionLock(uuid)) { + onlineUserSessions.put(uuid, new OnlineSessionState(true, onlineSessionGeneration.incrementAndGet())); + } + } + + /** Capture an online session from a Bukkit/Folia-owned player event. */ + public void markUserOnline(Player player) { + markUserOnline(onlineStorageUuid(player)); + } + + public void markUserOffline(UUID uuid) { + if (uuid != null) synchronized (onlineSessionLock(uuid)) { + onlineUserSessions.put(uuid, new OnlineSessionState(false, onlineSessionGeneration.incrementAndGet())); + } + } + + /** Remove an online session from a Bukkit/Folia-owned player event. */ + public void markUserOffline(Player player) { + markUserOffline(onlineStorageUuid(player)); + } + + private void clearNonNeededCachedUsers(Set onlineSnapshot) { plugin.devDebug("Clearing cache for non online players (if any)"); - ArrayList onlineUUIDS = new ArrayList<>(); - for (Player p : Bukkit.getOnlinePlayers()) onlineUUIDS.add(p.getUniqueId()); int removed = 0; - for (UUID uuid : userDataCache.keySet()) { - if (!onlineUUIDS.contains(uuid)) { removeCache(uuid, null); removed++; } + for (UUID uuid : Set.copyOf(userDataCache.keySet())) { + if (onlineSnapshot.contains(uuid) || isUserOnline(uuid)) continue; + UserDataCache expected = userDataCache.get(uuid); + if (expected == null) continue; + long expectedVersion = expected.getSharedSnapshotVersion(); + java.util.concurrent.atomic.AtomicBoolean retired = new java.util.concurrent.atomic.AtomicBoolean(); + java.util.concurrent.atomic.AtomicBoolean invalidatedForJoin = new java.util.concurrent.atomic.AtomicBoolean(); + withSharedSqlBackendExclusive(uuid, () -> { + if (isUserOnline(uuid)) return; + UserDataCache current = userDataCache.get(uuid); + if (current != expected || current.getSharedSnapshotVersion() != expectedVersion) return; + current.beginRemoval(); + if (isUserOnline(uuid)) { + current.cancelRemoval(); + return; + } + try { + current.clearCache(); + } catch (RuntimeException | Error failure) { + current.cancelRemoval(); + throw failure; + } + synchronized (onlineSessionLock(uuid)) { + if (isUserOnline(uuid)) { + if (sharedSqlRoute != null) { + try { current.retireAfterSharedFlush(); } + catch (RuntimeException | Error failure) { + current.cancelRemoval(); + throw failure; + } + } + boolean cacheRemoved = retireSharedCache(uuid, current); + retired.set(cacheRemoved); + invalidatedForJoin.set(cacheRemoved); + return; + } + if (sharedSqlRoute != null) { + try { current.retireAfterSharedFlush(); } + catch (RuntimeException | Error failure) { + current.cancelRemoval(); + throw failure; + } + } + boolean cacheRemoved = retireSharedCache(uuid, current); + retired.set(cacheRemoved); + } + }); + Consumer listener = sharedCacheRemovalListener; + if (retired.get() && listener != null) listener.accept(uuid); + if (retired.get() && !invalidatedForJoin.get()) removed++; } if (removed > 0) plugin.devDebug("Removed " + removed + " cached users who are no longer online"); } - public boolean containsKey(UUID fromString) { return userDataCache.containsKey(fromString); } - /** Return an already-published cache snapshot without creating or populating one. */ + private boolean isUserOnline(UUID uuid) { + OnlineSessionState state = onlineUserSessions.get(uuid); + return state != null && state.online(); + } + public UserDataCache getPublishedCache(UUID uuid) { UserDataCache cache = userDataCache.get(uuid); return cache != null && cache.hasPublishedStorageSnapshot() ? cache : null; } + public boolean containsKey(UUID uuid) { return userDataCache.containsKey(uuid); } public UserDataCache getCache(UUID uuid) { if (hasSharedSqlBackend() && isPlatformOwnedThread()) { UserDataCache cache = userDataCache.get(uuid); diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java index 6e102c45f..0f9aac5fa 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/listeners/PlayerJoinEvent.java @@ -65,11 +65,13 @@ public void onJoin(AdvancedCoreLoginEvent event) { } } - @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) { - if (plugin == null || !plugin.isEnabled() || !plugin.isLoadUserData()) { - return; - } + if (plugin == null || !plugin.isEnabled()) { + return; + } + plugin.getUserManager().getDataManager().markUserOnline(event.getPlayer()); + if (!plugin.isLoadUserData()) return; if (!plugin.getOptions().isHideLoginMessage()) { plugin.getLogger() @@ -77,7 +79,6 @@ public void onPlayerLogin(final org.bukkit.event.player.PlayerJoinEvent event) { } else { plugin.debug("Login: " + event.getPlayer().getName() + " (" + event.getPlayer().getUniqueId() + ")"); } - Player joiningPlayer = event.getPlayer(); synchronized (loginSessionLock(joiningPlayer.getUniqueId())) { pendingLoginSessions.put(joiningPlayer.getUniqueId(), joiningPlayer); @@ -170,6 +171,7 @@ public void onPlayerQuit(PlayerQuitEvent event) { Player player = event.getPlayer(); plugin.debug("Logout: " + player.getName() + " (" + player.getUniqueId() + ")"); + plugin.getUserManager().getDataManager().markUserOffline(player); synchronized (loginSessionLock(player.getUniqueId())) { pendingLoginSessions.remove(player.getUniqueId(), player); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java index a01945327..66550011f 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/listeners/PlayerJoinEventSessionTest.java @@ -15,6 +15,21 @@ import com.bencodez.advancedcore.api.permissions.PermissionHandler; class PlayerJoinEventSessionTest { + @Test + void joinTracksOnlineStateWhenUserDataLoadingIsDisabled() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.isLoadUserData()).thenReturn(false); + Player player = mock(Player.class); + org.bukkit.event.player.PlayerJoinEvent join = mock(org.bukkit.event.player.PlayerJoinEvent.class); + when(join.getPlayer()).thenReturn(player); + + new PlayerJoinEvent(plugin).onPlayerLogin(join); + + verify(plugin.getUserManager().getDataManager()).markUserOnline(player); + verify(plugin.getLoginTimer(), never()).schedule(any(Runnable.class), anyLong(), any(TimeUnit.class)); + } + @Test void delayedLoginDoesNotRestorePermissionsAfterThePlayerQuit() { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataManagerCacheCleanupThreadingTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataManagerCacheCleanupThreadingTest.java new file mode 100644 index 000000000..9f3609fe9 --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/UserDataManagerCacheCleanupThreadingTest.java @@ -0,0 +1,296 @@ +package com.bencodez.advancedcore.tests.user; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +import org.bukkit.Bukkit; +import org.bukkit.Server; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.user.usercache.UserDataCache; +import com.bencodez.advancedcore.api.user.usercache.UserDataManager; +import com.bencodez.advancedcore.core.user.storage.sql.SqlUserBackend; +import com.bencodez.simpleapi.scheduler.BukkitScheduler; +import com.bencodez.simpleapi.sql.data.DataValueInt; + +class UserDataManagerCacheCleanupThreadingTest { + @Test + void noServerCaptureSkipsBukkitOnlinePlayerLookup() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + java.util.concurrent.ScheduledExecutorService worker = mock(java.util.concurrent.ScheduledExecutorService.class); + java.lang.reflect.Field timerField = UserDataManager.class.getDeclaredField("timer"); + timerField.setAccessible(true); + timerField.set(manager, worker); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(null); + manager.clearNonNeededCachedUsers(); + bukkit.verify(Bukkit::getOnlinePlayers, never()); + verify(worker).execute(any(Runnable.class)); + } + } + + @Test + void failedSharedRetirementReopensMappedCache() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + when(plugin.isEnabled()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + java.util.concurrent.ScheduledExecutorService worker = mock(java.util.concurrent.ScheduledExecutorService.class); + java.lang.reflect.Field timerField = UserDataManager.class.getDeclaredField("timer"); + timerField.setAccessible(true); + timerField.set(manager, worker); + SqlUserBackend backend = mock(SqlUserBackend.class); + manager.bindSharedSqlBackend(backend, (uuid, operation) -> operation.run()); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + when(cache.getSharedSnapshotVersion()).thenReturn(1L); + doThrow(new IllegalStateException("unflushed work")).when(cache).retireAfterSharedFlush(); + manager.getUserDataCache().put(uuid, cache); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(null); + ArgumentCaptor storage = ArgumentCaptor.forClass(Runnable.class); + manager.clearNonNeededCachedUsers(); + verify(worker).execute(storage.capture()); + assertThrows(IllegalStateException.class, storage.getValue()::run); + verify(cache).cancelRemoval(); + assertTrue(manager.containsKey(uuid)); + } + } + + @Test + void platformSchedulerRejectionDoesNotStopLaterCleanupAttempts() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + doThrow(new IllegalStateException("scheduler unavailable")) + .doNothing().when(scheduler).runTask(eq(plugin), any(Runnable.class)); + UserDataManager manager = new UserDataManager(plugin); + Server server = mock(Server.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + assertDoesNotThrow(manager::clearNonNeededCachedUsers); + assertDoesNotThrow(manager::clearNonNeededCachedUsers); + verify(scheduler, times(2)).runTask(eq(plugin), any(Runnable.class)); + assertTrue(manager.getLastDeferredStorageFailure() instanceof IllegalStateException); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test + void capturesOnlinePlayersOnlyFromPlatformTask() { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions().isOnlineMode()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + Server server = mock(Server.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(java.util.List.of()); + manager.clearNonNeededCachedUsers(); + bukkit.verify(Bukkit::getOnlinePlayers, never()); + ArgumentCaptor platform = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler).runTask(eq(plugin), platform.capture()); + platform.getValue().run(); + bukkit.verify(Bukkit::getOnlinePlayers); + } finally { manager.getTimer().shutdownNow(); } + } + + @Test + void reconcilesSessionMarkersOnlyOnStorageWorker() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + java.util.concurrent.ScheduledExecutorService worker = mock(java.util.concurrent.ScheduledExecutorService.class); + java.lang.reflect.Field timerField = UserDataManager.class.getDeclaredField("timer"); + timerField.setAccessible(true); + timerField.set(manager, worker); + UUID uuid = UUID.randomUUID(); + manager.markUserOffline(uuid); + java.lang.reflect.Field sessionsField = UserDataManager.class.getDeclaredField("onlineUserSessions"); + sessionsField.setAccessible(true); + @SuppressWarnings("unchecked") + ConcurrentHashMap sessions = (ConcurrentHashMap) sessionsField.get(manager); + Server server = mock(Server.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(java.util.List.of()); + ArgumentCaptor platform = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor storage = ArgumentCaptor.forClass(Runnable.class); + manager.clearNonNeededCachedUsers(); + verify(scheduler).runTask(eq(plugin), platform.capture()); + platform.getValue().run(); + assertTrue(sessions.containsKey(uuid)); + verify(worker).execute(storage.capture()); + storage.getValue().run(); + assertFalse(sessions.containsKey(uuid)); + } + } + + @Test + void olderQueuedSnapshotCannotEraseNewerQuitTombstone() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions().isOnlineMode()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + java.util.concurrent.ScheduledExecutorService worker = mock(java.util.concurrent.ScheduledExecutorService.class); + java.lang.reflect.Field timerField = UserDataManager.class.getDeclaredField("timer"); + timerField.setAccessible(true); + timerField.set(manager, worker); + UUID uuid = UUID.randomUUID(); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(uuid); + Server server = mock(Server.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getOnlinePlayers) + .thenReturn(java.util.List.of(), java.util.List.of(player), java.util.List.of()); + ArgumentCaptor platform = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor storage = ArgumentCaptor.forClass(Runnable.class); + + manager.clearNonNeededCachedUsers(); + manager.clearNonNeededCachedUsers(); + verify(scheduler, times(2)).runTask(eq(plugin), platform.capture()); + platform.getAllValues().forEach(Runnable::run); + verify(worker, times(2)).execute(storage.capture()); + manager.markUserOffline(player); + storage.getAllValues().forEach(Runnable::run); + + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + manager.clearNonNeededCachedUsers(); + ArgumentCaptor allPlatformTasks = ArgumentCaptor.forClass(Runnable.class); + verify(scheduler, times(3)).runTask(eq(plugin), allPlatformTasks.capture()); + allPlatformTasks.getAllValues().get(2).run(); + ArgumentCaptor allStorageTasks = ArgumentCaptor.forClass(Runnable.class); + verify(worker, times(3)).execute(allStorageTasks.capture()); + allStorageTasks.getAllValues().get(2).run(); + assertFalse(manager.containsKey(uuid)); + } + } + + @Test + void joinAfterPlatformSnapshotPreventsWorkerEviction() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions().isOnlineMode()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + java.util.concurrent.ScheduledExecutorService worker = mock(java.util.concurrent.ScheduledExecutorService.class); + java.lang.reflect.Field timerField = UserDataManager.class.getDeclaredField("timer"); + timerField.setAccessible(true); + timerField.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + Server server = mock(Server.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(java.util.List.of()); + ArgumentCaptor platform = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor storage = ArgumentCaptor.forClass(Runnable.class); + manager.clearNonNeededCachedUsers(); + verify(scheduler).runTask(eq(plugin), platform.capture()); + platform.getValue().run(); + verify(worker).execute(storage.capture()); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(uuid); + manager.markUserOnline(player); + storage.getValue().run(); + assertTrue(manager.containsKey(uuid)); + } + } + + @Test + void concurrentQuitCannotBeOverwrittenByPlatformSnapshot() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + BukkitScheduler scheduler = mock(BukkitScheduler.class); + when(plugin.getBukkitScheduler()).thenReturn(scheduler); + when(plugin.isEnabled()).thenReturn(true); + when(plugin.getOptions().isOnlineMode()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + java.util.concurrent.ScheduledExecutorService worker = mock(java.util.concurrent.ScheduledExecutorService.class); + java.lang.reflect.Field timerField = UserDataManager.class.getDeclaredField("timer"); + timerField.setAccessible(true); + timerField.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new java.util.HashMap<>(java.util.Map.of("Points", new DataValueInt(1)))); + manager.getUserDataCache().put(uuid, cache); + org.bukkit.entity.Player player = mock(org.bukkit.entity.Player.class); + when(player.getUniqueId()).thenReturn(uuid); + Server server = mock(Server.class); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::getOnlinePlayers).thenReturn(java.util.List.of(player)); + ArgumentCaptor platform = ArgumentCaptor.forClass(Runnable.class); + ArgumentCaptor storage = ArgumentCaptor.forClass(Runnable.class); + manager.markUserOnline(player); + manager.clearNonNeededCachedUsers(); + verify(scheduler).runTask(eq(plugin), platform.capture()); + manager.markUserOffline(player); + platform.getValue().run(); + verify(worker).execute(storage.capture()); + storage.getValue().run(); + assertTrue(!manager.containsKey(uuid)); + } + } + + @Test + void joinDuringBlockedFlushInvalidatesClearedCacheWithoutBlockingJoin() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class, RETURNS_DEEP_STUBS); + when(plugin.isEnabled()).thenReturn(true); + UserDataManager manager = new UserDataManager(plugin); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = mock(UserDataCache.class); + when(cache.getSharedSnapshotVersion()).thenReturn(1L); + CountDownLatch flushStarted = new CountDownLatch(1); + CountDownLatch releaseFlush = new CountDownLatch(1); + doAnswer(invocation -> { + flushStarted.countDown(); + assertTrue(releaseFlush.await(5, TimeUnit.SECONDS)); + return null; + }).when(cache).clearCache(); + manager.getUserDataCache().put(uuid, cache); + try (MockedStatic bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(null); + manager.clearNonNeededCachedUsers(); + assertTrue(flushStarted.await(5, TimeUnit.SECONDS)); + long started = System.nanoTime(); + manager.markUserOnline(uuid); + assertTrue(TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started) < 500); + releaseFlush.countDown(); + manager.getTimer().shutdown(); + assertTrue(manager.getTimer().awaitTermination(5, TimeUnit.SECONDS)); + assertFalse(manager.containsKey(uuid)); + verify(cache, never()).cancelRemoval(); + verify(cache, never()).retireAfterSharedFlush(); + } finally { manager.getTimer().shutdownNow(); } + } +}