Skip to content

Commit 01eaa71

Browse files
committed
perf: porting board to off-thread
1 parent 82f3372 commit 01eaa71

3 files changed

Lines changed: 216 additions & 15 deletions

File tree

src/main/java/com/conaxgames/libraries/LibraryPlugin.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ public LibraryPlugin onEnable(JavaPlugin plugin, String debugPrimary, String deb
105105
* @return This LibraryPlugin instance for chaining
106106
*/
107107
public LibraryPlugin onDisable() {
108+
// Shutdown board manager if it exists
109+
if (this.boardManager != null) {
110+
this.boardManager.shutdown();
111+
}
112+
108113
this.moduleManager.disableAllModules();
109114
return this;
110115
}

src/main/java/com/conaxgames/libraries/board/BoardEntry.java

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -146,11 +146,19 @@ public BoardEntry send(int position) {
146146
suffix = suffix.substring(0, MAX_SUFFIX_LENGTH);
147147
}
148148

149-
this.team.setPrefix(prefix);
150-
this.team.setSuffix(suffix);
149+
// Only update team prefix/suffix if they actually changed (reduces packet spam)
150+
if (!prefix.equals(this.team.getPrefix())) {
151+
this.team.setPrefix(prefix);
152+
}
153+
if (!suffix.equals(this.team.getSuffix())) {
154+
this.team.setSuffix(suffix);
155+
}
151156

152157
Score score = objective.getScore(this.key);
153-
score.setScore(position);
158+
// Only update score if it changed
159+
if (score.getScore() != position) {
160+
score.setScore(position);
161+
}
154162

155163
return this;
156164
}

src/main/java/com/conaxgames/libraries/board/BoardManager.java

Lines changed: 200 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import com.conaxgames.libraries.LibraryPlugin;
44
import com.conaxgames.libraries.util.CC;
5+
import com.conaxgames.libraries.util.TaskUtil;
56
import net.md_5.bungee.api.ChatColor;
67
import org.bukkit.entity.Player;
78
import org.bukkit.scoreboard.DisplaySlot;
@@ -10,6 +11,12 @@
1011
import org.bukkit.scoreboard.Scoreboard;
1112

1213
import java.util.*;
14+
import java.util.concurrent.ConcurrentHashMap;
15+
import java.util.concurrent.ConcurrentLinkedQueue;
16+
import java.util.concurrent.CompletableFuture;
17+
import java.util.concurrent.ExecutorService;
18+
import java.util.concurrent.Executors;
19+
import java.util.concurrent.TimeUnit;
1320

1421
/**
1522
* Manages scoreboard boards for all players.
@@ -24,10 +31,26 @@ public class BoardManager implements Runnable {
2431
private static final String C_ELEMENT_METADATA_KEY = "cElement";
2532
private static final String DEFAULT_OBJECTIVE_NAME = "Default";
2633
private static final String DEFAULT_OBJECTIVE_CRITERIA = "dummy";
34+
private static final int ASYNC_THREAD_POOL_SIZE = 2; // Small pool for board data preparation
35+
private static final long CACHE_EXPIRY_MS = 100L; // Cache board data for 100ms to reduce redundant calls
2736

28-
// Instance fields
29-
private final Map<UUID, Board> playerBoards = new HashMap<>();
37+
// Thread-safe data structures for async/sync communication
38+
private final Map<UUID, Board> playerBoards = new ConcurrentHashMap<>();
3039
private final BoardAdapter adapter;
40+
private final ExecutorService asyncExecutor;
41+
42+
// Cache structures for performance optimization
43+
private final Map<UUID, BoardUpdateData> pendingUpdates = new ConcurrentHashMap<>();
44+
private final Map<UUID, Long> lastUpdateTimes = new ConcurrentHashMap<>();
45+
46+
// Queue for batched sync operations
47+
private final ConcurrentLinkedQueue<Runnable> syncOperations = new ConcurrentLinkedQueue<>();
48+
49+
// Performance monitoring (optional)
50+
private long totalAsyncTime = 0;
51+
private long totalSyncTime = 0;
52+
private int updateCount = 0;
53+
private volatile boolean shutdown = false;
3154

3255
/**
3356
* Creates a new board manager with the specified adapter.
@@ -36,16 +59,65 @@ public class BoardManager implements Runnable {
3659
*/
3760
public BoardManager(BoardAdapter adapter) {
3861
this.adapter = adapter;
62+
this.asyncExecutor = Executors.newFixedThreadPool(ASYNC_THREAD_POOL_SIZE, r -> {
63+
Thread thread = new Thread(r, "BoardManager-Async-" + System.currentTimeMillis());
64+
thread.setDaemon(true);
65+
return thread;
66+
});
67+
}
68+
69+
/**
70+
* Shuts down the async executor and cleans up resources.
71+
* Should be called when the plugin is disabled.
72+
*/
73+
public void shutdown() {
74+
shutdown = true;
75+
asyncExecutor.shutdown();
76+
try {
77+
if (!asyncExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
78+
asyncExecutor.shutdownNow();
79+
}
80+
} catch (InterruptedException e) {
81+
asyncExecutor.shutdownNow();
82+
Thread.currentThread().interrupt();
83+
}
84+
}
85+
86+
/**
87+
* Internal class to hold board update data prepared asynchronously.
88+
*/
89+
private static class BoardUpdateData {
90+
final List<String> scores;
91+
final String title;
92+
final long timestamp;
93+
final UUID playerUUID;
94+
95+
BoardUpdateData(UUID playerUUID, List<String> scores, String title) {
96+
this.playerUUID = playerUUID;
97+
this.scores = scores;
98+
this.title = title;
99+
this.timestamp = System.currentTimeMillis();
100+
}
101+
102+
boolean isExpired() {
103+
return System.currentTimeMillis() - timestamp > CACHE_EXPIRY_MS;
104+
}
39105
}
40106

41107
@Override
42108
public void run() {
109+
if (shutdown) return;
110+
111+
long startTime = System.nanoTime();
112+
43113
this.adapter.preLoop();
44114

45115
// Clean up any boards for players with cElement metadata (zero CPU cost)
46116
cleanupAllCElementBoards();
47117

48-
// Only process players who actually have boards - major performance boost
118+
// Step 1: Prepare board data asynchronously for all players
119+
List<CompletableFuture<Void>> asyncTasks = new ArrayList<>();
120+
49121
for (Map.Entry<UUID, Board> entry : this.playerBoards.entrySet()) {
50122
UUID playerUUID = entry.getKey();
51123
Board board = entry.getValue();
@@ -60,27 +132,121 @@ public void run() {
60132
continue;
61133
}
62134

135+
// Check if we have recent cached data to avoid redundant async calls
136+
BoardUpdateData cached = pendingUpdates.get(playerUUID);
137+
Long lastUpdate = lastUpdateTimes.get(playerUUID);
138+
long now = System.currentTimeMillis();
139+
140+
if (cached != null && !cached.isExpired()) {
141+
// Use cached data, skip async preparation
142+
continue;
143+
}
144+
145+
// Prepare data asynchronously
146+
CompletableFuture<Void> asyncTask = CompletableFuture.runAsync(() -> {
147+
try {
148+
long asyncStart = System.nanoTime();
149+
150+
// These expensive calls now run off the main thread
151+
List<String> scores = this.adapter.getScoreboard(player, board);
152+
String title = this.adapter.getTitle(player);
153+
154+
// Store prepared data for sync processing
155+
pendingUpdates.put(playerUUID, new BoardUpdateData(playerUUID, scores, title));
156+
157+
totalAsyncTime += (System.nanoTime() - asyncStart);
158+
} catch (Exception e) {
159+
LibraryPlugin.getInstance().getPlugin().getLogger()
160+
.warning("Error preparing board data for " + player.getName() + ": " + e.getMessage());
161+
}
162+
}, asyncExecutor);
163+
164+
asyncTasks.add(asyncTask);
165+
}
166+
167+
// Step 2: Wait for all async tasks to complete (with timeout)
168+
CompletableFuture<Void> allTasks = CompletableFuture.allOf(
169+
asyncTasks.toArray(new CompletableFuture[0])
170+
);
171+
172+
try {
173+
// Wait for async tasks with timeout to prevent blocking
174+
allTasks.get(50, TimeUnit.MILLISECONDS); // Max 50ms wait
175+
} catch (Exception e) {
176+
// Log timeout but continue - we'll use cached data or skip updates
177+
if (updateCount % 100 == 0) { // Only log occasionally to avoid spam
178+
LibraryPlugin.getInstance().getPlugin().getLogger()
179+
.fine("Board async preparation timeout - continuing with available data");
180+
}
181+
}
182+
183+
// Step 3: Apply all scoreboard updates synchronously in batch
184+
long syncStart = System.nanoTime();
185+
processPendingUpdates();
186+
totalSyncTime += (System.nanoTime() - syncStart);
187+
188+
updateCount++;
189+
190+
// Optional: Log performance metrics periodically
191+
if (updateCount % 200 == 0) {
192+
logPerformanceMetrics();
193+
}
194+
}
195+
196+
/**
197+
* Processes all pending board updates synchronously.
198+
* This method applies the data prepared asynchronously to the actual scoreboards.
199+
*/
200+
private void processPendingUpdates() {
201+
for (Map.Entry<UUID, BoardUpdateData> entry : pendingUpdates.entrySet()) {
202+
UUID playerUUID = entry.getKey();
203+
BoardUpdateData updateData = entry.getValue();
204+
205+
// Skip expired data
206+
if (updateData.isExpired()) {
207+
continue;
208+
}
209+
210+
Board board = playerBoards.get(playerUUID);
211+
if (board == null) continue;
212+
213+
Player player = LibraryPlugin.getInstance().getPlugin().getServer().getPlayer(playerUUID);
214+
if (player == null || !player.isOnline()) {
215+
continue;
216+
}
217+
218+
// Skip players with cElement metadata - they shouldn't have boards
219+
if (player.hasMetadata(C_ELEMENT_METADATA_KEY)) {
220+
continue;
221+
}
222+
63223
try {
64-
updatePlayerBoard(player, board);
224+
updatePlayerBoardSync(player, board, updateData);
225+
lastUpdateTimes.put(playerUUID, System.currentTimeMillis());
65226
} catch (Exception e) {
66-
e.printStackTrace();
67227
LibraryPlugin.getInstance().getPlugin().getLogger()
68-
.severe("Something went wrong while updating " + player.getName() + "'s scoreboard " + board + " - " + board.getAdapter() + ")");
228+
.severe("Error updating scoreboard for " + player.getName() + ": " + e.getMessage());
229+
e.printStackTrace();
69230
}
70231
}
232+
233+
// Clean up expired data
234+
pendingUpdates.entrySet().removeIf(entry -> entry.getValue().isExpired());
71235
}
72-
236+
73237
/**
74-
* Updates the scoreboard for a specific player.
238+
* Updates the scoreboard for a specific player using pre-prepared data.
239+
* This method only performs the synchronous scoreboard API operations.
75240
*
76241
* @param player The player to update the board for
77242
* @param board The board instance for this player
243+
* @param updateData Pre-prepared update data from async thread
78244
*/
79-
private void updatePlayerBoard(Player player, Board board) {
245+
private void updatePlayerBoardSync(Player player, Board board, BoardUpdateData updateData) {
80246
Scoreboard scoreboard = board.getScoreboard();
81247
Objective objective = board.getObjective();
82248

83-
List<String> scores = this.adapter.getScoreboard(player, board);
249+
List<String> scores = updateData.scores;
84250

85251
if (scores == null || scores.isEmpty()) {
86252
// Clear all entries if no scores
@@ -94,8 +260,8 @@ private void updatePlayerBoard(Player player, Board board) {
94260
Collections.reverse(scores);
95261

96262
// Update title if changed
97-
String newTitle = this.adapter.getTitle(player);
98-
if (!objective.getDisplayName().equals(newTitle)) {
263+
String newTitle = updateData.title;
264+
if (newTitle != null && !objective.getDisplayName().equals(newTitle)) {
99265
objective.setDisplayName(newTitle);
100266
}
101267

@@ -106,6 +272,28 @@ private void updatePlayerBoard(Player player, Board board) {
106272
player.setScoreboard(scoreboard);
107273
}
108274

275+
/**
276+
* Logs performance metrics for monitoring board update performance.
277+
*/
278+
private void logPerformanceMetrics() {
279+
if (updateCount == 0) return;
280+
281+
long avgAsyncTime = totalAsyncTime / updateCount;
282+
long avgSyncTime = totalSyncTime / updateCount;
283+
284+
LibraryPlugin.getInstance().getPlugin().getLogger().info(
285+
String.format("Board Performance: Updates=%d, AvgAsync=%dns, AvgSync=%dns, ActiveBoards=%d",
286+
updateCount, avgAsyncTime, avgSyncTime, playerBoards.size())
287+
);
288+
289+
// Reset counters periodically to prevent overflow
290+
if (updateCount >= 1000) {
291+
totalAsyncTime = 0;
292+
totalSyncTime = 0;
293+
updateCount = 0;
294+
}
295+
}
296+
109297
/**
110298
* Updates the board entries efficiently by reusing existing entries when possible.
111299
*

0 commit comments

Comments
 (0)