diff --git a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java index 7429aeb..94bbdf0 100644 --- a/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java +++ b/src/main/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolver.java @@ -11,10 +11,22 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; -/** Resolves DataRegistryApi snapshots without importing or managing DataRegistryApi entities. */ +/** + * Resolves immutable DataRegistry player snapshots without importing or managing DataRegistry entities. + * + *

Synchronous methods are compatibility helpers. They are cache-first and may use a bounded + * persisted fallback only from background threads. They never wait for persistence on a likely + * server, event-loop, or Netty thread. New code that needs offline-player correctness should use + * the explicit asynchronous methods.

+ */ public final class PlayerReferenceResolver { + private static final long PERSISTED_LOOKUP_TIMEOUT_SECONDS = 3L; + private final PlayerDirectory playerDirectory; public PlayerReferenceResolver(DataRegistryApi dataRegistry) { @@ -25,37 +37,222 @@ public PlayerReferenceResolver(PlayerDirectory playerDirectory) { this.playerDirectory = Objects.requireNonNull(playerDirectory, "playerDirectory"); } - public Optional findByUuid(UUID uuid) { return findIdentityByUuid(uuid).map(PlayerReference::from); } - public Optional findByUuid(String uuid) { return findIdentityByUuid(uuid).map(PlayerReference::from); } - public Optional findByIdentifier(String identifier) { return findIdentityByIdentifier(identifier).map(PlayerReference::from); } - public Optional findIdentityByUuid(UUID uuid) { return playerDirectory.findActiveIdentityCached(uuid); } - public Optional findIdentityByUuid(String uuid) { return playerDirectory.findActiveIdentityCached(uuid); } + public Optional findByUuid(UUID uuid) { + return findIdentityByUuid(uuid).map(PlayerReference::from); + } + + public Optional findByUuid(String uuid) { + return findIdentityByUuid(uuid).map(PlayerReference::from); + } + + public Optional findByIdentifier(String identifier) { + return findIdentityByIdentifier(identifier).map(PlayerReference::from); + } + + public CompletionStage> findByUuidAsync(UUID uuid) { + return findIdentityByUuidAsync(uuid).thenApply(identity -> identity.map(PlayerReference::from)); + } + + public CompletionStage> findByUuidAsync(String uuid) { + return findIdentityByUuidAsync(uuid).thenApply(identity -> identity.map(PlayerReference::from)); + } + + public CompletionStage> findByIdentifierAsync(String identifier) { + return findIdentityByIdentifierAsync(identifier).thenApply(identity -> identity.map(PlayerReference::from)); + } + + public Optional findIdentityByUuid(UUID uuid) { + if (uuid == null) { + return Optional.empty(); + } + Optional cached = playerDirectory.findActiveIdentityCached(uuid); + if (cached.isPresent() || !mayWaitForPersistence()) { + return cached; + } + return await(playerDirectory.findByUuid(uuid)); + } + + public Optional findIdentityByUuid(String uuid) { + if (uuid == null || uuid.isBlank()) { + return Optional.empty(); + } + String normalized = uuid.trim(); + Optional cached = playerDirectory.findActiveIdentityCached(normalized); + if (cached.isPresent() || !mayWaitForPersistence()) { + return cached; + } + return await(playerDirectory.findByUuid(normalized)); + } + + public CompletionStage> findIdentityByUuidAsync(UUID uuid) { + if (uuid == null) { + return completedEmpty(); + } + Optional cached = playerDirectory.findActiveIdentityCached(uuid); + return cached.isPresent() + ? CompletableFuture.completedFuture(cached) + : playerDirectory.findByUuid(uuid); + } + + public CompletionStage> findIdentityByUuidAsync(String uuid) { + if (uuid == null || uuid.isBlank()) { + return completedEmpty(); + } + String normalized = uuid.trim(); + Optional cached = playerDirectory.findActiveIdentityCached(normalized); + return cached.isPresent() + ? CompletableFuture.completedFuture(cached) + : playerDirectory.findByUuid(normalized); + } + public Optional findIdentityByUsername(String username) { - if (username == null || username.isBlank()) return Optional.empty(); - String normalized = username.trim().toLowerCase(Locale.ROOT); - return playerDirectory.snapshotActiveIdentities().values().stream() - .filter(identity -> identity.username().equalsIgnoreCase(normalized)).findFirst(); + if (username == null || username.isBlank()) { + return Optional.empty(); + } + String normalized = username.trim(); + Optional cached = findCachedIdentityByUsername(normalized); + if (cached.isPresent() || !mayWaitForPersistence()) { + return cached; + } + return await(playerDirectory.findByUsernameIgnoreCase(normalized)); + } + + public CompletionStage> findIdentityByUsernameAsync(String username) { + if (username == null || username.isBlank()) { + return completedEmpty(); + } + String normalized = username.trim(); + Optional cached = findCachedIdentityByUsername(normalized); + return cached.isPresent() + ? CompletableFuture.completedFuture(cached) + : playerDirectory.findByUsernameIgnoreCase(normalized); } + public Optional findIdentityByIdentifier(String identifier) { - if (identifier == null || identifier.isBlank()) return Optional.empty(); - try { return findIdentityByUuid(UUID.fromString(identifier.trim())); } - catch (IllegalArgumentException ignored) { return findIdentityByUsername(identifier); } + if (identifier == null || identifier.isBlank()) { + return Optional.empty(); + } + String normalized = identifier.trim(); + try { + return findIdentityByUuid(UUID.fromString(normalized)); + } catch (IllegalArgumentException ignored) { + return findIdentityByUsername(normalized); + } + } + + public CompletionStage> findIdentityByIdentifierAsync(String identifier) { + if (identifier == null || identifier.isBlank()) { + return completedEmpty(); + } + String normalized = identifier.trim(); + try { + return findIdentityByUuidAsync(UUID.fromString(normalized)); + } catch (IllegalArgumentException ignored) { + return findIdentityByUsernameAsync(normalized); + } } + + /** + * Compatibility lookup by stable player id. Despite the historical method name, this method is + * cache-first and may use persistence from a background thread. + */ public Optional findActiveIdentityById(Long playerId) { - if (playerId == null || playerId <= 0L) return Optional.empty(); - return playerDirectory.snapshotActiveIdentities().values().stream() - .filter(identity -> playerId.equals(identity.playerId())).findFirst(); + if (playerId == null || playerId <= 0L) { + return Optional.empty(); + } + Optional cached = findCachedIdentityById(playerId); + if (cached.isPresent() || !mayWaitForPersistence()) { + return cached; + } + return await(playerDirectory.findByPlayerId(playerId)); } + + public CompletionStage> findIdentityByIdAsync(Long playerId) { + if (playerId == null || playerId <= 0L) { + return completedEmpty(); + } + Optional cached = findCachedIdentityById(playerId); + return cached.isPresent() + ? CompletableFuture.completedFuture(cached) + : playerDirectory.findByPlayerId(playerId); + } + public CompletionStage> findPersistedIdentityByIdentifier(String identifier) { - return playerDirectory.findByIdentifier(identifier); + if (identifier == null || identifier.isBlank()) { + return completedEmpty(); + } + return playerDirectory.findByIdentifier(identifier.trim()); } - public CompletableFuture> whenReady(UUID uuid) { return playerDirectory.whenReady(uuid); } - public PlayerReference resolveManaged(Session ignored, UUID uuid) { return findByUuid(uuid).orElse(null); } - public PlayerReference resolveManaged(Session ignored, String uuid) { return findByUuid(uuid).orElse(null); } + + public CompletableFuture> whenReady(UUID uuid) { + return playerDirectory.whenReady(uuid); + } + + public PlayerReference resolveManaged(Session ignored, UUID uuid) { + return findByUuid(uuid).orElse(null); + } + + public PlayerReference resolveManaged(Session ignored, String uuid) { + return findByUuid(uuid).orElse(null); + } + public PlayerReference resolveManagedById(Session ignored, Long playerId) { - if (playerId == null || playerId <= 0L) return null; + if (playerId == null || playerId <= 0L) { + return null; + } + return findActiveIdentityById(playerId) + .map(PlayerReference::from) + .orElseGet(() -> PlayerReference.byId(playerId)); + } + + private Optional findCachedIdentityByUsername(String username) { + String normalized = username.toLowerCase(Locale.ROOT); + return playerDirectory.snapshotActiveIdentities().values().stream() + .filter(identity -> identity.username().toLowerCase(Locale.ROOT).equals(normalized)) + .findFirst(); + } + + private Optional findCachedIdentityById(Long playerId) { return playerDirectory.snapshotActiveIdentities().values().stream() .filter(identity -> playerId.equals(identity.playerId())) - .findFirst().map(PlayerReference::from).orElseGet(() -> PlayerReference.byId(playerId)); + .findFirst(); + } + + private static boolean mayWaitForPersistence() { + return !isLikelyServerEventThread(Thread.currentThread().getName()); + } + + static boolean isLikelyServerEventThread(String threadName) { + if (threadName == null) { + return false; + } + String normalized = threadName.toLowerCase(Locale.ROOT); + return normalized.contains("server thread") + || normalized.contains("main") + || normalized.contains("event") + || normalized.contains("netty"); + } + + private static Optional await(CompletionStage> stage) { + if (stage == null) { + return Optional.empty(); + } + CompletableFuture> future = stage.toCompletableFuture(); + try { + Optional result = future.get(PERSISTED_LOOKUP_TIMEOUT_SECONDS, TimeUnit.SECONDS); + return result == null ? Optional.empty() : result; + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + return Optional.empty(); + } catch (TimeoutException exception) { + future.cancel(true); + return Optional.empty(); + } catch (ExecutionException | RuntimeException exception) { + return Optional.empty(); + } + } + + private static CompletionStage> completedEmpty() { + return CompletableFuture.completedFuture(Optional.empty()); } } diff --git a/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java b/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java index a7aabc4..2b4b51d 100644 --- a/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java +++ b/src/test/java/nl/hauntedmc/proxyfeatures/framework/persistence/PlayerReferenceResolverTest.java @@ -8,9 +8,14 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -20,7 +25,7 @@ class PlayerReferenceResolverTest { @Test - void resolveManagedUsesActiveIdentityWithoutTouchingFeatureOrm() { + void resolveManagedUsesActiveIdentityWithoutQueryingPersistence() { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); @@ -36,32 +41,146 @@ void resolveManagedUsesActiveIdentityWithoutTouchingFeatureOrm() { } @Test - void resolveManagedReturnsNullWhenNoActiveSnapshotExists() { + void resolveManagedUsesPersistedIdentityFromBackgroundWorkerWhenPlayerIsOffline() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); + PlayerIdentity identity = new PlayerIdentity(13L, uuid, "OfflineAlice"); when(directory.findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); + when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); - PlayerReference result = new PlayerReferenceResolver(directory).resolveManaged(session, uuid); + PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); + PlayerReference result = runOnThread("ProxyFeatures-Votifier-worker", () -> resolver.resolveManaged(session, uuid)); - assertNull(result); - verify(directory, never()).findByUuid(uuid); + assertEquals(PlayerReference.from(identity), result); + verify(directory).findByUuid(uuid); verifyNoInteractions(session); } @Test - void resolveManagedByIdUsesSnapshotAndFallsBackToScalarId() { + void synchronousLookupNeverQueriesPersistenceFromEventThread() throws Exception { + PlayerDirectory directory = mock(PlayerDirectory.class); + UUID uuid = UUID.randomUUID(); + + when(directory.snapshotActiveIdentities()).thenReturn(Map.of()); + + PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); + Optional result = runOnThread( + "Velocity Netty EventLoop", + () -> resolver.findByIdentifier("OfflineAlice") + ); + + assertEquals(Optional.empty(), result); + verify(directory, never()).findByUsernameIgnoreCase("OfflineAlice"); + verify(directory, never()).findByUuid(uuid); + } + + @Test + void asyncLookupQueriesPersistenceWithoutBlockingEventThread() throws Exception { + PlayerDirectory directory = mock(PlayerDirectory.class); + UUID uuid = UUID.randomUUID(); + PlayerIdentity identity = new PlayerIdentity(14L, uuid, "OfflineAlice"); + + when(directory.snapshotActiveIdentities()).thenReturn(Map.of()); + when(directory.findByUsernameIgnoreCase("OfflineAlice")) + .thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); + + PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); + Optional result = runOnThread( + "Velocity Netty EventLoop", + () -> resolver.findByIdentifierAsync("OfflineAlice").toCompletableFuture().join() + ); + + assertEquals(Optional.of(PlayerReference.from(identity)), result); + verify(directory).findByUsernameIgnoreCase("OfflineAlice"); + } + + @Test + void resolveManagedReturnsNullWhenPersistedIdentityDoesNotExist() throws Exception { PlayerDirectory directory = mock(PlayerDirectory.class); Session session = mock(Session.class); UUID uuid = UUID.randomUUID(); - PlayerIdentity identity = new PlayerIdentity(22L, uuid, "Bob"); - when(directory.snapshotActiveIdentities()).thenReturn(Map.of(uuid.toString(), identity)); + + when(directory.findActiveIdentityCached(uuid)).thenReturn(Optional.empty()); + when(directory.findByUuid(uuid)).thenReturn(CompletableFuture.completedFuture(Optional.empty())); + + PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); + PlayerReference result = runOnThread("ProxyFeatures-worker", () -> resolver.resolveManaged(session, uuid)); + + assertNull(result); + verify(directory).findByUuid(uuid); + verifyNoInteractions(session); + } + + @Test + void findByIdentifierAsyncUsesPersistedCaseInsensitiveUsernameWhenOffline() { + PlayerDirectory directory = mock(PlayerDirectory.class); + UUID uuid = UUID.randomUUID(); + PlayerIdentity identity = new PlayerIdentity(21L, uuid, "Alice"); + + when(directory.snapshotActiveIdentities()).thenReturn(Map.of()); + when(directory.findByUsernameIgnoreCase("aLiCe")) + .thenReturn(CompletableFuture.completedFuture(Optional.of(identity))); + + Optional result = new PlayerReferenceResolver(directory) + .findByIdentifierAsync("aLiCe") + .toCompletableFuture() + .join(); + + assertEquals(Optional.of(PlayerReference.from(identity)), result); + verify(directory).findByUsernameIgnoreCase("aLiCe"); + } + + @Test + void resolveManagedByIdUsesActiveThenPersistedIdentityAndFinallyScalarId() throws Exception { + PlayerDirectory directory = mock(PlayerDirectory.class); + Session session = mock(Session.class); + UUID activeUuid = UUID.randomUUID(); + UUID offlineUuid = UUID.randomUUID(); + PlayerIdentity activeIdentity = new PlayerIdentity(22L, activeUuid, "Bob"); + PlayerIdentity offlineIdentity = new PlayerIdentity(23L, offlineUuid, "OfflineBob"); + + when(directory.snapshotActiveIdentities()) + .thenReturn(Map.of(activeUuid.toString(), activeIdentity), Map.of(), Map.of()); + when(directory.findByPlayerId(23L)) + .thenReturn(CompletableFuture.completedFuture(Optional.of(offlineIdentity))); + when(directory.findByPlayerId(24L)) + .thenReturn(CompletableFuture.completedFuture(Optional.empty())); PlayerReferenceResolver resolver = new PlayerReferenceResolver(directory); - assertEquals(PlayerReference.from(identity), resolver.resolveManagedById(session, 22L)); - assertEquals(PlayerReference.byId(23L), resolver.resolveManagedById(session, 23L)); + assertEquals(PlayerReference.from(activeIdentity), resolver.resolveManagedById(session, 22L)); + assertEquals( + PlayerReference.from(offlineIdentity), + runOnThread("ProxyFeatures-worker", () -> resolver.resolveManagedById(session, 23L)) + ); + assertEquals( + PlayerReference.byId(24L), + runOnThread("ProxyFeatures-worker", () -> resolver.resolveManagedById(session, 24L)) + ); verifyNoInteractions(session); } + + @Test + void detectsLikelyServerAndEventThreads() { + assertTrue(PlayerReferenceResolver.isLikelyServerEventThread("Server thread")); + assertTrue(PlayerReferenceResolver.isLikelyServerEventThread("Velocity Netty EventLoop")); + assertTrue(PlayerReferenceResolver.isLikelyServerEventThread("main")); + assertFalse(PlayerReferenceResolver.isLikelyServerEventThread("ProxyFeatures-Votifier-worker")); + assertFalse(PlayerReferenceResolver.isLikelyServerEventThread(null)); + } + + private static T runOnThread(String name, Supplier supplier) throws Exception { + CompletableFuture result = new CompletableFuture<>(); + Thread thread = new Thread(() -> { + try { + result.complete(supplier.get()); + } catch (Throwable throwable) { + result.completeExceptionally(throwable); + } + }, name); + thread.start(); + return result.get(5, TimeUnit.SECONDS); + } }