Class PlayerCacheManager

java.lang.Object
com.ultikits.ultitools.manager.PlayerCacheManager

@Internal public class PlayerCacheManager extends Object
Manages automatic cleanup of @PlayerCache-annotated fields when players quit.

Three field shapes are recognised: a Map keyed by UUID (the entire entry -- including a nested value, e.g. Map<UUID, Map<String, Long>> -- is removed on quit), a Set<UUID> (the UUID itself is removed), and a Map whose VALUE is a UUID (every entry whose value equals the quitting player's UUID is removed, other players' entries are left intact). A field whose generic shape cannot be resolved to one of these three is refused at registration time -- an annotation that cannot take effect fails loudly rather than being silently skipped.

A non-bean instance -- one that is never resolved from any SimpleContainer, e.g. a command validator new-ed directly in a constructor -- can register itself via tryRegister(Object) even before the core plugin is enabled; see that method's javadoc.

Since:
6.2.0
  • Field Details

    • LOGGER

      private static final Logger LOGGER
    • EXPIRY_SWEEP_PERIOD_TICKS

      static final long EXPIRY_SWEEP_PERIOD_TICKS
      Period, in ticks, between two consecutive expiry sweeps (sweepExpiredEntries()).

      Chosen conservatively: this task runs for the entire lifetime of a long-running server, and the per-player state it exists to bound (command cooldowns, usage locks) is expressed in seconds-to-minutes, not milliseconds -- there is no correctness requirement for the sweep to run more often than once every few minutes. 6000 ticks is 5 minutes at the server's nominal 20 ticks/second, which keeps the sweep's own overhead negligible (an empty or small registry costs microseconds per pass) while bounding the worst-case staleness of any expired entry to 5 minutes.

      See Also:
    • trackedBeans

      private final List<PlayerCacheManager.TrackedBean> trackedBeans
  • Constructor Details

    • PlayerCacheManager

      public PlayerCacheManager()
  • Method Details

    • tryRegister

      public static void tryRegister(Object instance)
      Attempts lazy first-use registration of a non-bean instance with the live PlayerCacheManager singleton.

      Accepts any object -- it does not need to be a container-managed bean; this is the entry point objects that are new-ed directly (e.g. a command validator constructed inside BaseCommandExecutor's constructor) use to reach the sweep mechanism, since PluginManager's bean-iteration registration loops only ever see objects resolved from a plugin's SimpleContainer.

      Safe to call before the core plugin is enabled. If UltiTools.getInstance() (or its PluginManager / PlayerCacheManager) is not yet available -- for example, a bare new MyCommand() constructed in a unit test with no running server -- this method returns without throwing and without registering anything. It is intended to be retried on a later first-use once the core plugin is available; degrading gracefully to "never registered, never swept" in a pure-unit-test context is deliberate (D-03).

      Idempotent per instance. Calling this method more than once with the same instance (by reference identity, not equals) results in exactly one tracked entry and one sweep per player quit -- see registerBean(Object).

      The caller owns unregistration. This method has no matching automatic teardown; a caller that registers itself is responsible for calling tryUnregister(Object) on its own teardown, so the manager does not keep tracking an instance whose owner has gone away.

      Parameters:
      instance - the object to register; scanned for PlayerCache-annotated fields
      Since:
      6.3.0
    • tryUnregister

      public static void tryUnregister(Object instance)
      The static counterpart to tryRegister(Object) -- unregisters a previously registered non-bean instance from the live PlayerCacheManager singleton.

      Safe to call before the core plugin is enabled or after it has been disabled: if the live manager cannot be resolved, this is a no-op. Also safe to call on an instance that was never registered (e.g. because an earlier tryRegister(Object) attempt found the core plugin unavailable) -- unregisterBean(Object) is itself a no-op in that case.

      Parameters:
      instance - the object to stop tracking
      Since:
      6.3.0
    • resolveLive

      private static PlayerCacheManager resolveLive()
    • registerBean

      public void registerBean(Object bean)
      Scans a bean for @PlayerCache fields and tracks them.

      Idempotent by reference identity: registering the same instance more than once is a no-op on the second and subsequent calls, so an instance never accumulates duplicate tracked entries or is swept more than once per player quit.

      Throws:
      PluginModuleException - if a PlayerCache-annotated field's shape is none of the three this manager can sweep (a Map keyed by UUID, a Set<UUID>, or a Map whose value is a UUID) -- an annotation that cannot take effect must fail loudly rather than be silently skipped.
    • isTracked

      private boolean isTracked(Object bean)
    • classifyField

      private static PlayerCacheManager.FieldShape classifyField(Field field)
      Classifies a PlayerCache-annotated field's generic shape.

      Java 8 erasure means the key-side and value-side Map cases cannot be told apart from Field.getType() alone -- this reads Field.getGenericType() and its actual type arguments instead. Where the generic signature is absent or unresolvable (a raw type, or neither side is UUID), this returns null rather than guessing, and the caller refuses the field.

      Parameters:
      field - the field to classify
      Returns:
      the recognised shape, or null if the field's shape is unsupported
    • isUuid

      private static boolean isUuid(Type type)
    • unregisterBean

      public void unregisterBean(Object bean)
      Removes a bean from tracking.
    • onPlayerQuit

      public void onPlayerQuit(UUID playerId)
      Called when a player quits. Cleans up all tracked fields across all three supported shapes.

      For each tracked field, in turn: if the field is @PlayerCache(saveBeforeRemove = true) and its bean implements PlayerCacheSaver, PlayerCacheSaver.savePlayerData(UUID) runs first; then the quitting player's entry is removed from the field.

      Each field is isolated (as of 6.3.0, #643). A save or a removal that throws -- any Exception, such as a module's database error or a checked exception the saver throws without declaring it (Lombok @SneakyThrows), or an Error such as a NoClassDefFoundError from a module whose classes are gone, contained here as the module unload steps contain it -- is logged as one WARNING naming the bean class, the field and the player, and the cleanup continues with every other field of every tracked bean. The beans are visited from a snapshot, as sweepExpiredEntries() does, so a saver that registers or unregisters a tracked object during the quit does not end the cleanup.

      A failed save does not keep the entry: the entry is removed anyway (maintainer decision, 2026-10-08), because saveBeforeRemove orders the save before the removal and does not make the removal conditional on it. The framework does not retry the save; a module that must not lose the data retries or persists it itself, inside savePlayerData. Nothing is thrown to the caller (the framework's quit listener). Before 6.3.0 the first such failure ended the cleanup: that field's entry and the entries of every later field were left in memory, and the exception escaped into Bukkit's event dispatch.

      Parameters:
      playerId - the UUID of the player who quit
    • logQuitFailure

      private static void logQuitFailure(String action, PlayerCacheManager.TrackedBean tracked, PlayerCacheManager.TrackedField tf, UUID playerId, Throwable failure)
    • sweepField

      private static void sweepField(Object value, PlayerCacheManager.FieldShape shape, UUID playerId)
    • getTrackedBeanCount

      public int getTrackedBeanCount()
      Returns the number of beans being tracked.
    • sweepExpiredEntries

      public void sweepExpiredEntries()
      Periodic, time-based expiry sweep -- driven by the framework's own Scheduled (i.e. @Scheduled) mechanism rather than a hand-rolled BukkitRunnable, because this state is stale on a clock, not on a player-quit event: it must be pruned whether or not any player is online (D-03 -- mirrors CooldownValidator.cleanupExpired's shape).

      Every tracked instance that implements PlayerCacheManager.ExpiringPlayerCache has PlayerCacheManager.ExpiringPlayerCache.sweepExpired() invoked once per pass. One participant's hook throwing -- an exception, or (as of 6.3.0, #643) an Error such as a NoClassDefFoundError -- is caught, logged, and does not prevent the remaining participants' hooks from running in the same pass. An instance that does not implement PlayerCacheManager.ExpiringPlayerCache is not visited at all -- opt-in by type, not by reflection guessing at method names. Tolerates an empty registry and being invoked mid-shutdown: it touches only this manager's own tracked list, never the Bukkit API directly.

      Sweep period: see EXPIRY_SWEEP_PERIOD_TICKS (5 minutes -- see that constant's javadoc for the rationale).

      Since:
      6.3.0