Skip to content

Commit 855a05b

Browse files
committed
Refactor SkullCacheHandler to improve caching logic and error handling
1 parent d79cdff commit 855a05b

File tree

1 file changed

+188
-51
lines changed

1 file changed

+188
-51
lines changed
Lines changed: 188 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
package com.bencodez.simpleapi.skull;
22

33
import java.util.Queue;
4+
import java.util.Set;
45
import java.util.UUID;
6+
import java.util.concurrent.ConcurrentHashMap;
57
import java.util.concurrent.ConcurrentLinkedQueue;
68
import java.util.concurrent.Executors;
79
import java.util.concurrent.ScheduledExecutorService;
10+
import java.util.concurrent.ScheduledFuture;
11+
import java.util.concurrent.ThreadLocalRandom;
812
import java.util.concurrent.TimeUnit;
13+
import java.util.concurrent.atomic.AtomicInteger;
14+
import java.util.concurrent.atomic.AtomicLong;
915

1016
import org.bukkit.Material;
1117
import org.bukkit.inventory.ItemStack;
@@ -14,43 +20,73 @@
1420
import lombok.Setter;
1521

1622
public abstract class SkullCacheHandler {
23+
1724
@Getter
18-
private int skullDelayTime = 4000;
25+
private volatile int skullDelayTime = 4000;
26+
27+
private volatile int currentDelayMs;
1928

20-
Queue<String> skullsToLoad = new ConcurrentLinkedQueue<>();
29+
private final Queue<String> skullsToLoad = new ConcurrentLinkedQueue<>();
30+
private final Set<String> queuedOrLoading = ConcurrentHashMap.newKeySet();
2131

22-
private boolean pause = false;
32+
private volatile boolean paused = false;
2333

2434
@Getter
2535
@Setter
2636
private String bedrockPrefix = ".";
2737

28-
private ScheduledExecutorService timer = Executors.newScheduledThreadPool(1);
38+
private final ScheduledExecutorService timer = Executors.newScheduledThreadPool(1);
39+
private volatile ScheduledFuture<?> workerFuture;
2940

30-
public SkullCacheHandler() {
41+
private final int minDelayMs = 250;
42+
private final int maxDelayMs = 30_000;
43+
private final int pauseMinSeconds = 120;
44+
private final int pauseMaxSeconds = 240;
45+
46+
private volatile int backoffMultiplier = 1;
47+
48+
private final long successDecayEveryMs = 60_000;
49+
private final AtomicLong lastErrorAt = new AtomicLong(0);
50+
private final AtomicLong lastSuccessAt = new AtomicLong(0);
51+
52+
private final AtomicInteger rateLimitHitCount = new AtomicInteger(0);
3153

54+
public SkullCacheHandler() {
55+
this.currentDelayMs = clamp(skullDelayTime, minDelayMs, maxDelayMs);
3256
}
3357

3458
public SkullCacheHandler(int skullDelayTime) {
3559
this.skullDelayTime = skullDelayTime;
60+
this.currentDelayMs = clamp(skullDelayTime, minDelayMs, maxDelayMs);
3661
}
3762

3863
public void addToCache(UUID uuid, String name) {
39-
String text = uuid.toString() + "/" + name;
40-
if (uuid.toString().charAt(14) == '3') {
41-
debugLog("Can't cache skull of offline player uuid: " + uuid.toString() + "/" + name);
64+
if (uuid == null || name == null) {
65+
return;
66+
}
67+
68+
String uuidStr = uuid.toString();
69+
70+
if (uuidStr.length() > 14 && uuidStr.charAt(14) == '3') {
4271
return;
4372
}
73+
4474
if (name.startsWith(bedrockPrefix)) {
45-
debugLog("Can't cache skull of bedrock player");
4675
return;
4776
}
48-
if (!skullsToLoad.contains(text) && !SkullCache.isLoaded(uuid)) {
49-
if (name.length() <= 16) {
50-
skullsToLoad.add(text);
51-
} else {
52-
debugLog("Player name too long to preload skull: " + uuid.toString() + "/" + name);
53-
}
77+
78+
if (name.length() > 16) {
79+
return;
80+
}
81+
82+
if (SkullCache.isLoaded(uuid)) {
83+
return;
84+
}
85+
86+
String key = uuidStr + "/" + name;
87+
88+
if (queuedOrLoading.add(key)) {
89+
skullsToLoad.add(key);
5490
}
5591
}
5692

@@ -59,7 +95,13 @@ public void changeApiProfileURL(String url) {
5995
}
6096

6197
public void close() {
62-
timer.shutdownNow();
98+
try {
99+
if (workerFuture != null) {
100+
workerFuture.cancel(false);
101+
}
102+
} finally {
103+
timer.shutdownNow();
104+
}
63105
}
64106

65107
public abstract void debugException(Exception e);
@@ -74,65 +116,160 @@ public void flushCache() {
74116
public ItemStack getSkull(UUID uuid, String playerName) {
75117
Material skullMaterial;
76118
ItemStack skullItem;
77-
// Check for legacy versions
119+
78120
try {
79121
skullMaterial = Material.valueOf("PLAYER_HEAD");
80122
skullItem = new ItemStack(skullMaterial);
81123
} catch (IllegalArgumentException e) {
82-
// Fallback
83124
skullMaterial = Material.valueOf("SKULL_ITEM");
84125
skullItem = new ItemStack(skullMaterial, 1, (short) 3);
85126
}
86-
if (playerName.length() > 16 || (pause && !SkullCache.isLoaded(uuid)) || uuid.toString().charAt(14) == '3') {
127+
128+
if (uuid == null || playerName == null) {
87129
return skullItem;
88130
}
131+
132+
String uuidStr = uuid.toString();
133+
134+
if (playerName.length() > 16) {
135+
return skullItem;
136+
}
137+
138+
if (paused && !SkullCache.isLoaded(uuid)) {
139+
return skullItem;
140+
}
141+
142+
if (uuidStr.length() > 14 && uuidStr.charAt(14) == '3') {
143+
return skullItem;
144+
}
145+
89146
try {
90147
return SkullCache.getSkull(uuid, playerName);
91148
} catch (Exception e) {
92-
pauseCaching();
149+
onCacheError(e);
93150
return skullItem;
94151
}
95152
}
96153

97154
public abstract void log(String log);
98155

99156
public void pauseCaching() {
100-
log("Pausing skull caching due to hitting rate limit or an error, increasing delay for caching");
101-
pause = true;
102-
skullDelayTime += 3000;
103-
timer.schedule(new Runnable() {
104-
105-
@Override
106-
public void run() {
107-
unPuase();
108-
}
109-
}, 15, TimeUnit.MINUTES);
157+
onCacheError(null);
110158
}
111159

112160
public void startTimer() {
113-
timer.scheduleWithFixedDelay(new Runnable() {
114-
115-
@Override
116-
public void run() {
117-
if (!skullsToLoad.isEmpty() && !pause) {
118-
String text = skullsToLoad.remove();
119-
try {
120-
String[] data = text.split("/");
121-
String uuid = data[0];
122-
String name = data[1];
123-
SkullCache.cacheSkull(UUID.fromString(uuid), name);
124-
debugLog("Loaded skull: " + uuid + "/" + name);
125-
} catch (Exception e) {
126-
debugLog("Failed to load skull: " + text);
127-
debugException(e);
128-
pauseCaching();
129-
}
130-
}
161+
scheduleWorker(60_000, currentDelayMs);
162+
}
163+
164+
private void scheduleWorker(long initialDelayMs, int delayMs) {
165+
int clamped = clamp(delayMs, minDelayMs, maxDelayMs);
166+
this.currentDelayMs = clamped;
167+
168+
if (workerFuture != null) {
169+
workerFuture.cancel(false);
170+
}
171+
172+
workerFuture = timer.scheduleWithFixedDelay(() -> {
173+
try {
174+
workerTick();
175+
} catch (Exception e) {
176+
debugException(e);
177+
}
178+
}, initialDelayMs, this.currentDelayMs, TimeUnit.MILLISECONDS);
179+
}
180+
181+
private void workerTick() {
182+
if (paused) {
183+
tryDecayBackoff();
184+
return;
185+
}
186+
187+
String text = skullsToLoad.poll();
188+
if (text == null) {
189+
tryDecayBackoff();
190+
return;
191+
}
192+
193+
try {
194+
String[] data = text.split("/", 2);
195+
if (data.length != 2) {
196+
return;
131197
}
132-
}, 1000 * 60, skullDelayTime, TimeUnit.MILLISECONDS);
198+
199+
SkullCache.cacheSkull(UUID.fromString(data[0]), data[1]);
200+
debugLog("Skull cached: " + data[0] + "/" + data[1] + " (queue=" + skullsToLoad.size() + ")");
201+
lastSuccessAt.set(System.currentTimeMillis());
202+
tryDecayBackoff();
203+
204+
} catch (Exception e) {
205+
debugException(e);
206+
onCacheError(e);
207+
} finally {
208+
queuedOrLoading.remove(text);
209+
}
210+
}
211+
212+
private void onCacheError(Exception e) {
213+
long now = System.currentTimeMillis();
214+
lastErrorAt.set(now);
215+
216+
int hits = rateLimitHitCount.incrementAndGet();
217+
218+
backoffMultiplier = Math.min(backoffMultiplier * 2, 64);
219+
220+
int base = clamp(skullDelayTime, minDelayMs, maxDelayMs);
221+
int newDelay = clamp(base * backoffMultiplier, minDelayMs, maxDelayMs);
222+
newDelay = addJitter(newDelay, 0.15);
223+
224+
int pauseSeconds = ThreadLocalRandom.current().nextInt(pauseMinSeconds, pauseMaxSeconds + 1);
225+
paused = true;
226+
227+
if (hits == 1) {
228+
debugLog("Skull caching rate limit detected. Pausing for ~" + pauseSeconds + "s.");
229+
} else {
230+
log("Skull caching still rate limited (" + hits + " hits). Delay now " + newDelay + "ms.");
231+
}
232+
233+
scheduleWorker(0, newDelay);
234+
235+
timer.schedule(() -> paused = false, pauseSeconds, TimeUnit.SECONDS);
236+
}
237+
238+
private void tryDecayBackoff() {
239+
long now = System.currentTimeMillis();
240+
241+
long lastErr = lastErrorAt.get();
242+
if (lastErr != 0 && (now - lastErr) < successDecayEveryMs) {
243+
return;
244+
}
245+
246+
long lastOk = lastSuccessAt.get();
247+
if (lastOk == 0 || (now - lastOk) < successDecayEveryMs) {
248+
return;
249+
}
250+
251+
if (backoffMultiplier > 1) {
252+
backoffMultiplier = Math.max(1, backoffMultiplier / 2);
253+
254+
int base = clamp(skullDelayTime, minDelayMs, maxDelayMs);
255+
int newDelay = clamp(base * backoffMultiplier, minDelayMs, maxDelayMs);
256+
newDelay = addJitter(newDelay, 0.10);
257+
258+
if (Math.abs(newDelay - currentDelayMs) >= 50) {
259+
scheduleWorker(0, newDelay);
260+
}
261+
262+
lastSuccessAt.set(now);
263+
}
264+
}
265+
266+
private static int clamp(int v, int min, int max) {
267+
return Math.max(min, Math.min(max, v));
133268
}
134269

135-
private void unPuase() {
136-
pause = false;
270+
private static int addJitter(int valueMs, double pct) {
271+
int spread = (int) Math.max(1, Math.round(valueMs * pct));
272+
int delta = ThreadLocalRandom.current().nextInt(-spread, spread + 1);
273+
return Math.max(1, valueMs + delta);
137274
}
138275
}

0 commit comments

Comments
 (0)