Player Cache
Since v6.2.0
The @PlayerCache annotation is available starting from UltiTools-API v6.2.0.
Plugins often store per-player data in Map<UUID, ?> fields (cooldowns, settings, open GUIs, etc.). If you forget to clean up these maps when a player quits, you get a memory leak. UltiTools provides the @PlayerCache annotation to automatically remove entries when a player disconnects.
Basic Usage
Annotate any Map<UUID, ?> field in a managed bean with @PlayerCache:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class CooldownService {
@PlayerCache
private final Map<UUID, Long> cooldowns = new ConcurrentHashMap<>();
public boolean isOnCooldown(UUID playerId) {
Long expiry = cooldowns.get(playerId);
return expiry != null && System.currentTimeMillis() < expiry;
}
public void setCooldown(UUID playerId, long durationMs) {
cooldowns.put(playerId, System.currentTimeMillis() + durationMs);
}
}When a player quits, the framework automatically calls cooldowns.remove(playerUuid). No manual cleanup needed.
Caching Stored Data
A @PlayerCache map works best as a read cache for data that is stored elsewhere, such as a database table. Read through the cache, write each change when it is made, and let the framework drop the entry when the player quits:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.PlayerCache;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.entities.WhereCondition;
import com.ultikits.ultitools.interfaces.DataOperator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class PlayerSettingsService {
@Autowired
private UltiToolsPlugin plugin;
// A read cache only. Nothing is ever written from it: the framework drops the entry when the
// player quits, and a change is written at the moment it is made.
@PlayerCache
private final Map<UUID, PlayerSettings> settingsCache = new ConcurrentHashMap<>();
public PlayerSettings getSettings(UUID playerId) {
return settingsCache.computeIfAbsent(playerId, id -> {
PlayerSettingsEntity stored = readStored(id);
return stored != null ? PlayerSettings.fromEntity(stored) : new PlayerSettings();
});
}
public void setShowScoreboard(UUID playerId, boolean show) {
DataOperator<PlayerSettingsEntity> operator = plugin.getDataOperator(PlayerSettingsEntity.class);
// Read the stored row now, not the cache: another server may have created or changed it.
PlayerSettingsEntity stored = readStored(playerId);
if (stored == null) {
operator.insert(PlayerSettingsEntity.builder()
.playerId(playerId.toString())
.showScoreboard(show)
.build());
} else {
// Write only the column that changed, so nothing else in the row is touched.
operator.update("show_scoreboard", show, stored.getId());
}
// Drop the cached copy; the next read loads what is stored.
settingsCache.remove(playerId);
}
private PlayerSettingsEntity readStored(UUID playerId) {
List<PlayerSettingsEntity> rows = plugin.getDataOperator(PlayerSettingsEntity.class)
.getAll(WhereCondition.builder().column("player_id").value(playerId.toString()).build());
return rows.isEmpty() ? null : rows.get(0);
}
}setShowScoreboard reads the stored row, writes the one column that changed, and removes the cached entry so the next read loads the stored value. Nothing is written when the player quits, because the map holds no change that has not already been written. Do not write a cached copy back to the stored row; see Caching on a Shared MySQL Database.
Save Before Remove
Set saveBeforeRemove = true and implement the PlayerCacheSaver interface to have the framework call savePlayerData(playerUuid) just before it removes the entry. This suits state that exists only in this server's memory and must be written once when the player leaves. It does not suit a cached copy of a stored row, because the copy can be older than the row.
The time a player joined this server is such state: no stored row holds it. The service below appends one new row for the finished session when the player quits, and reads or overwrites no stored row:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.PlayerCache;
import com.ultikits.ultitools.annotations.PlayerCacheSaver;
import com.ultikits.ultitools.annotations.PreDestroy;
import com.ultikits.ultitools.annotations.Service;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class PlaySessionService implements PlayerCacheSaver {
@Autowired
private UltiToolsPlugin plugin;
// State that exists only in this server's memory: when each player joined here.
// No stored row holds it, so there is nothing a copy could go stale against.
@PlayerCache(saveBeforeRemove = true)
private final Map<UUID, Long> joinedAt = new ConcurrentHashMap<>();
// Called from a join listener.
public void sessionStarted(UUID playerId) {
joinedAt.put(playerId, System.currentTimeMillis());
}
// Called by the framework when the player quits, just before the entry is removed.
@Override
public void savePlayerData(UUID playerId) {
Long start = joinedAt.get(playerId);
if (start == null) {
return;
}
// Append one new row for the finished session. No stored row is read or overwritten.
plugin.getDataOperator(PlaySessionEntity.class).insert(PlaySessionEntity.builder()
.playerId(playerId.toString())
.durationMillis(System.currentTimeMillis() - start)
.build());
}
// The framework does not call savePlayerData when the server stops (players are disconnected
// after plugins are disabled), so write the sessions that are still open.
@PreDestroy
public void saveOpenSessions() {
for (UUID playerId : joinedAt.keySet()) {
savePlayerData(playerId);
}
joinedAt.clear();
}
}The framework does not call savePlayerData for players who are still online when the server stops, so the @PreDestroy method writes their open sessions.
When a player quits, the framework:
- Calls
savePlayerData(playerUuid)(becausesaveBeforeRemove = true) - Removes the entry from the map
As of v6.3.0, each @PlayerCache field is handled on its own. If savePlayerData throws, for example because the database is unavailable, the framework logs one warning that names the bean class, the field and the player, removes the entry anyway, and goes on with every other field of every bean. Before v6.3.0 the first failure stopped the cleanup: that field's entry and the entries of every later field stayed in memory (#643). The framework does not retry a failed save; a module that must not lose the data retries or stores it itself, inside savePlayerData.
Caching on a Shared MySQL Database
Several servers can share one MySQL database, for example behind a proxy. A copy of a row that one server holds in memory can then be older than the stored row. A player leaves server A for server B and changes a setting there. If A still holds the row from before the change and writes it when the player quits, A's write replaces B's change with the old value. Writing cached copies at shutdown, or whole copies on every later change, has the same effect.
For data that is stored in a database, follow these rules:
- Keep the cache scoped to the player's time on this server. Load the entry when the player joins or on the first read, and let
@PlayerCachedrop it when the player quits. - Never write a cached copy back: not when a value changes, not on quit (
saveBeforeRemove), and not at shutdown. - For every change, read the stored row again, apply the change to that row, and write it.
- When the new value depends on the value that was read, such as a counter or a toggle, write with
updateIfconditioned on the values you read. Afalseresult means another writer changed the row first: read it again and decide again. Give up after a few attempts (UltiEconomy uses three). - Write an increment as a compare-and-set loop of this kind, never as the cached value plus the amount.
As of v6.3.0, DataOperator#updateIf writes the entity only while the stored row still has every value in the conditions; see Conditional Update. It writes every mapped field of the entity, so the conditions must cover every column the entity maps, not only the ones your code read or changed: a column left out is overwritten with the value you read if another server changed it in between. The sketch assumes PlayerStats maps just coins and kills besides its id. The sketch below adds coins to a stored row. It needs UltiTools-API v6.3.0 or later and is not part of the compiled examples:
// Requires UltiTools-API v6.3.0 or later (DataOperator#updateIf).
private static final int MAX_ATTEMPTS = 3;
public boolean addCoins(UUID playerId, int amount) {
DataOperator<PlayerStats> stats = plugin.getDataOperator(PlayerStats.class);
for (int attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
PlayerStats row = stats.getById(playerId.toString()); // the stored row, never the cache
if (row == null) {
return false;
}
int coins = row.getCoins();
int kills = row.getKills();
row.setCoins(coins + amount); // apply this one change
boolean written = stats.updateIf(row,
WhereCondition.builder().column("coins").value(coins).build(),
WhereCondition.builder().column("kills").value(kills).build());
if (written) {
return true;
}
// Another writer changed the row first: read it again and decide again.
}
return false; // contended: tell the player to try again instead of writing
}A change that does not depend on the value read, such as switching a setting to the value the player chose, can write just that column by id with update(column, value, id), as PlayerSettingsService does. On the JSON and SQLite backends the data belongs to one server, and the same code works unchanged.
The examples on this page insert a player's row the first time the player changes a setting. Give the table a unique index on the player column (player_id), so that two servers making a player's first-ever change at the same moment cannot both store a row: the second insert fails instead of storing a second row, and the caller can read the stored row and write its change to that.
Annotation Reference
| Attribute | Type | Default | Description |
|---|---|---|---|
saveBeforeRemove | boolean | false | If true, calls savePlayerData(UUID) on the bean before removing the entry. The bean must implement PlayerCacheSaver. |
PlayerCacheSaver Interface
public interface PlayerCacheSaver {
void savePlayerData(UUID playerId);
}This interface is optional. Only implement it when you use saveBeforeRemove = true.
Multiple Maps
A single bean can have multiple @PlayerCache fields. Each is cleaned up independently:
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.*;
import com.ultikits.ultitools.aop.ExceptionHandler;
import com.ultikits.ultitools.interfaces.DataOperator;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.lang.reflect.Method;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class GameService implements PlayerCacheSaver {
@PlayerCache
private final Map<UUID, Integer> scores = new ConcurrentHashMap<>();
@PlayerCache(saveBeforeRemove = true)
private final Map<UUID, Inventory> openInventories = new ConcurrentHashMap<>();
@PlayerCache
private final Map<UUID, Long> lastActivity = new ConcurrentHashMap<>();
@Override
public void savePlayerData(UUID playerId) {
// Called only for the saveBeforeRemove=true field,
// but you can save all data here
}
}Requirements
- The field must be a
MapwithUUIDkeys (as of v6.3.0 also aSet<UUID>, or aMapwhose values areUUIDs) - The field must be in a bean managed by the container (
@Service,@CmdExecutor,@EventListener) - For
saveBeforeRemove = true, the bean must implementPlayerCacheSaver - Use
ConcurrentHashMapif the map is accessed from async threads
Complete Example
package com.ultikits.docs.cache;
import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.PlayerCache;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.entities.WhereCondition;
import com.ultikits.ultitools.interfaces.DataOperator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class TeleportRequestService {
@Autowired
private UltiToolsPlugin plugin;
// Pending teleport requests: requester -> target. Held in memory only.
@PlayerCache
private final Map<UUID, UUID> pendingRequests = new ConcurrentHashMap<>();
// Player's teleport settings: a read cache of the stored row, dropped on quit and never written back.
@PlayerCache
private final Map<UUID, TeleportPrefs> preferences = new ConcurrentHashMap<>();
public void sendRequest(UUID from, UUID to) {
pendingRequests.put(from, to);
}
public UUID getRequest(UUID from) {
return pendingRequests.get(from);
}
public void acceptRequest(UUID from) {
pendingRequests.remove(from);
}
public TeleportPrefs getPreferences(UUID playerId) {
return preferences.computeIfAbsent(playerId, id -> {
TeleportPrefsEntity stored = readStored(id);
return stored != null ? TeleportPrefs.fromEntity(stored) : new TeleportPrefs(false);
});
}
public void setAutoAccept(UUID playerId, boolean autoAccept) {
DataOperator<TeleportPrefsEntity> operator = plugin.getDataOperator(TeleportPrefsEntity.class);
// The change is written when it is made, to the row as stored now.
TeleportPrefsEntity stored = readStored(playerId);
if (stored == null) {
operator.insert(TeleportPrefsEntity.builder()
.playerId(playerId.toString())
.autoAccept(autoAccept)
.build());
} else {
operator.update("auto_accept", autoAccept, stored.getId());
}
preferences.remove(playerId);
}
private TeleportPrefsEntity readStored(UUID playerId) {
List<TeleportPrefsEntity> rows = plugin.getDataOperator(TeleportPrefsEntity.class)
.getAll(WhereCondition.builder().column("player_id").value(playerId.toString()).build());
return rows.isEmpty() ? null : rows.get(0);
}
}Use @PlayerCache on every per-player map
@PlayerCache eliminates the most common source of memory leaks in Minecraft plugins. Use it on every Map<UUID, ?> field that stores per-player state.