From a57231aa83f7222267ae09b2d38bf808a7d5c8e3 Mon Sep 17 00:00:00 2001 From: bobhenl <18662086+bobhenl@users.noreply.github.com> Date: Fri, 30 Oct 2026 15:18:08 +0100 Subject: [PATCH] fix(user-cache): refill invalidated shared cache and cache tick-thread probes --- .../api/user/usercache/UserDataManager.java | 55 +++++++++++++++---- .../SharedCacheCleanupPrimaryThreadTest.java | 39 +++++++++++++ 2 files changed, 83 insertions(+), 11 deletions(-) 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 862264a5a..857ef3fa6 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 @@ -1349,7 +1349,12 @@ public UserDataCache getCache(UUID uuid) { if (hasSharedSqlBackend() && isPlatformOwnedThread()) { UserDataCache cache = userDataCache.get(uuid); if (cache == null) cache = ensureSharedCachePlaceholder(uuid); - if (!completedSharedCachePopulations.contains(uuid)) cacheUser(uuid, false); + // A completed population can later be unpublished by invalidateStorageSnapshot(). + // Queue a worker refill so the cache heals even if no caller arranged one; + // deferred populations are deduplicated per user, so this stays bounded. + if (!completedSharedCachePopulations.contains(uuid) || !cache.hasPublishedStorageSnapshot()) { + cacheUser(uuid, false); + } return cache; } cacheUserIfNeeded(uuid); @@ -1381,23 +1386,51 @@ public boolean isPlatformOwnedThread() { static boolean isFoliaTickThread(Object server) { if (server == null) return false; try { - java.lang.reflect.Method global = server.getClass().getMethod("isGlobalTickThread"); - if (Boolean.TRUE.equals(global.invoke(server))) return true; + java.lang.reflect.Method global = GLOBAL_TICK_METHODS.get(server.getClass()); + if (global != null && Boolean.TRUE.equals(global.invoke(server))) return true; } catch (ReflectiveOperationException | RuntimeException ignored) { } - if (reflectiveStaticBoolean("ca.spottedleaf.moonrise.common.util.TickThread", "isTickThread")) return true; - if (reflectiveStaticBoolean("io.papermc.paper.threadedregions.RegionizedServer", "isGlobalTickThread")) return true; + if (invokeStaticBoolean(FoliaProbes.MOONRISE_IS_TICK_THREAD)) return true; + if (invokeStaticBoolean(FoliaProbes.REGIONIZED_IS_GLOBAL_TICK_THREAD)) return true; try { - Class scheduler = Class.forName("io.papermc.paper.threadedregions.TickRegionScheduler"); - java.lang.reflect.Method current = scheduler.getMethod("getCurrentRegion"); - if (current.invoke(null) != null) return true; + java.lang.reflect.Method current = FoliaProbes.TICK_REGION_CURRENT_REGION; + if (current != null && current.invoke(null) != null) return true; } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { } return false; } - private static boolean reflectiveStaticBoolean(String className, String methodName) { + /** + * Lookups are resolved once. Resolving them on every call threw and filled a + * NoSuchMethodException/ClassNotFoundException per user data access on non-Folia servers. + */ + private static final ClassValue GLOBAL_TICK_METHODS = new ClassValue() { + @Override + protected java.lang.reflect.Method computeValue(Class type) { + try { + return type.getMethod("isGlobalTickThread"); + } catch (ReflectiveOperationException | RuntimeException e) { + return null; + } + } + }; + + private static final class FoliaProbes { + static final java.lang.reflect.Method MOONRISE_IS_TICK_THREAD = staticMethod("ca.spottedleaf.moonrise.common.util.TickThread", "isTickThread"); + static final java.lang.reflect.Method REGIONIZED_IS_GLOBAL_TICK_THREAD = staticMethod("io.papermc.paper.threadedregions.RegionizedServer", "isGlobalTickThread"); + static final java.lang.reflect.Method TICK_REGION_CURRENT_REGION = staticMethod("io.papermc.paper.threadedregions.TickRegionScheduler", "getCurrentRegion"); + + private static java.lang.reflect.Method staticMethod(String className, String methodName) { + try { + return Class.forName(className).getMethod(methodName); + } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { + return null; + } + } + } + + private static boolean invokeStaticBoolean(java.lang.reflect.Method method) { + if (method == null) return false; try { - Class type = Class.forName(className); - return Boolean.TRUE.equals(type.getMethod(methodName).invoke(null)); + return Boolean.TRUE.equals(method.invoke(null)); } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) { return false; } diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java index 33185b460..0b847d67b 100644 --- a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/user/SharedCacheCleanupPrimaryThreadTest.java @@ -891,6 +891,45 @@ void primaryThreadCachePopulationIsQueuedAndGetCacheReturnsAPopulationPlaceholde } } + @Test + void invalidatedCompletedCacheQueuesOneWorkerRefillOnPrimaryThreadAccess() throws Exception { + AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class); + UserDataManager manager = new UserDataManager(plugin); + manager.getTimer().shutdownNow(); + ScheduledExecutorService worker = mock(ScheduledExecutorService.class); + Field timer = UserDataManager.class.getDeclaredField("timer"); + timer.setAccessible(true); + timer.set(manager, worker); + UUID uuid = UUID.randomUUID(); + UserDataCache cache = new UserDataCache(manager, uuid); + cache.updateCache(new HashMap<>(Map.of("Points", new DataValueInt(38)))); + manager.getUserDataCache().put(uuid, cache); + Field completed = UserDataManager.class.getDeclaredField("completedSharedCachePopulations"); + completed.setAccessible(true); + @SuppressWarnings("unchecked") + Set completedPopulations = (Set) completed.get(manager); + completedPopulations.add(uuid); + SqlUserBackend backend = mock(SqlUserBackend.class); + when(backend.isOpen()).thenReturn(true); + manager.bindSharedSqlBackend(backend, (user, operation) -> { + throw new AssertionError("primary thread must not admit cache storage work"); + }); + Server server = mock(Server.class); + try (var bukkit = mockStatic(Bukkit.class)) { + bukkit.when(Bukkit::getServer).thenReturn(server); + bukkit.when(Bukkit::isPrimaryThread).thenReturn(true); + assertSame(cache, manager.getCache(uuid)); + verify(worker, never()).execute(any(Runnable.class)); + cache.invalidateStorageSnapshot("Points"); + assertSame(cache, manager.getCache(uuid)); + verify(worker, times(1)).execute(any(Runnable.class)); + // The queued refill is still pending, so repeated reads must not pile up work. + assertSame(cache, manager.getCache(uuid)); + verify(worker, times(1)).execute(any(Runnable.class)); + assertFalse(cache.hasPublishedStorageSnapshot()); + } + } + @Test void removingCompletedPlaceholderAllowsAReplacementPopulation() throws Exception { AdvancedCorePlugin plugin = mock(AdvancedCorePlugin.class);