Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import org.bukkit.World;
import org.bukkit.entity.EntityType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.util.EnumMap;
import java.util.Map;
Expand Down Expand Up @@ -70,7 +71,7 @@ public ChunkCuboid getChunkCuboid() {
return cuboid;
}

public abstract void getChunkSections(Consumer<@NotNull ChunkSection> sectionConsumer) throws IOException;
public abstract void getChunkSections(Consumer<@Nullable ChunkSection> sectionConsumer) throws IOException;

public abstract void getChunkEntities(Consumer<@NotNull ChunkEntity> entityConsumer) throws IOException;

Expand All @@ -90,6 +91,10 @@ public DistributionStorage get() {
int maxSectionY = blockMaxY >> 4;
try {
getChunkSections(section -> {
// Skip null sections - they represent empty sections that don't need to be counted
// unless we're calculating totals, but for limits we only care about non-air blocks
if (section == null) return;

int sectionY = section.index();
if (sectionY < minSectionY || sectionY > maxSectionY) return;
int minY = sectionY == minSectionY ? blockMinY & 15 : 0;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ public UnloadedChunkContainer(
}

@Override
public void getChunkSections(Consumer<@Nullable ChunkSection> sectionConsumer) {
public void getChunkSections(Consumer<@Nullable ChunkSection> sectionConsumer) throws IOException {
nms.getUnloadedChunkSections(world, chunkX, chunkZ, sectionConsumer);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,138 @@
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

/**
* Thread-safe LRU cache with TTL for addon region storage.
*/
public class AddonStorage {

private final Map<String, Storage> distributionMap;
private static final int DEFAULT_MAX_SIZE = 500;
private static final long DEFAULT_TTL_MINUTES = 10;
private static final long CLEANUP_INTERVAL_MINUTES = 1;

private final Map<String, CacheEntry> distributionMap;
private final int maxSize;
private final long ttlMillis;
private final ScheduledExecutorService cleanupExecutor;

public AddonStorage() {
this(DEFAULT_MAX_SIZE, DEFAULT_TTL_MINUTES);
}

public AddonStorage(int maxSize, long ttlMinutes) {
this.distributionMap = new ConcurrentHashMap<>();
this.maxSize = maxSize;
this.ttlMillis = TimeUnit.MINUTES.toMillis(ttlMinutes);
this.cleanupExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "Insights-AddonStorage-Cleanup");
t.setDaemon(true);
return t;
});
cleanupExecutor.scheduleAtFixedRate(
this::cleanupExpiredEntries,
CLEANUP_INTERVAL_MINUTES,
CLEANUP_INTERVAL_MINUTES,
TimeUnit.MINUTES
);
}

public Optional<Storage> get(String key) {
return Optional.ofNullable(distributionMap.get(key));
CacheEntry entry = distributionMap.get(key);
if (entry == null) {
return Optional.empty();
}
if (entry.isExpired(ttlMillis)) {
distributionMap.remove(key, entry);
return Optional.empty();
}
entry.touch();
return Optional.of(entry.getStorage());
}

public void put(String key, Storage storage) {
this.distributionMap.put(key, storage);
while (distributionMap.size() >= maxSize) {
evictOldest();
}
distributionMap.put(key, new CacheEntry(storage));
}

public void remove(String key) {
this.distributionMap.remove(key);
distributionMap.remove(key);
}

public int size() {
return distributionMap.size();
}

public void clear() {
distributionMap.clear();
}

public void shutdown() {
cleanupExecutor.shutdown();
try {
if (!cleanupExecutor.awaitTermination(5, TimeUnit.SECONDS)) {
cleanupExecutor.shutdownNow();
}
} catch (InterruptedException e) {
cleanupExecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}

private void cleanupExpiredEntries() {
long now = System.currentTimeMillis();
distributionMap.entrySet().removeIf(entry -> entry.getValue().isExpired(ttlMillis, now));
}

private void evictOldest() {
String oldestKey = null;
long oldestTime = Long.MAX_VALUE;
for (Map.Entry<String, CacheEntry> entry : distributionMap.entrySet()) {
long accessTime = entry.getValue().getLastAccessTime();
if (accessTime < oldestTime) {
oldestTime = accessTime;
oldestKey = entry.getKey();
}
}
if (oldestKey != null) {
distributionMap.remove(oldestKey);
}
}

private static class CacheEntry {
private final Storage storage;
private final long creationTime;
private volatile long lastAccessTime;

CacheEntry(Storage storage) {
this.storage = storage;
this.creationTime = System.currentTimeMillis();
this.lastAccessTime = this.creationTime;
}

Storage getStorage() {
return storage;
}

long getLastAccessTime() {
return lastAccessTime;
}

void touch() {
this.lastAccessTime = System.currentTimeMillis();
}

boolean isExpired(long ttlMillis) {
return isExpired(ttlMillis, System.currentTimeMillis());
}

boolean isExpired(long ttlMillis, long currentTime) {
return (currentTime - creationTime) > ttlMillis;
}
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,34 @@
package dev.frankheijden.insights.api.concurrent.storage;

import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
* Thread-safe LRU cache for chunk storage.
*/
public class ChunkStorage {

private static final int DEFAULT_MAX_CACHED_CHUNKS = 5000;
private final Map<Long, Storage> distributionMap;
private final int maxSize;

public ChunkStorage() {
this.distributionMap = new ConcurrentHashMap<>();
this(DEFAULT_MAX_CACHED_CHUNKS);
}

public ChunkStorage(int maxSize) {
this.maxSize = maxSize;
this.distributionMap = Collections.synchronizedMap(
new LinkedHashMap<>(maxSize, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, Storage> eldest) {
return size() > maxSize;
}
}
);
}

public Set<Long> getChunks() {
Expand All @@ -28,4 +46,12 @@ public void put(long chunkKey, Storage storage) {
public void remove(long chunkKey) {
distributionMap.remove(chunkKey);
}

public int size() {
return distributionMap.size();
}

public int getMaxSize() {
return maxSize;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,30 @@
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

/**
* Per-world chunk cache container.
*/
public class WorldStorage {

private static final int DEFAULT_CHUNK_CACHE_SIZE = 5000;

private final Map<UUID, ChunkStorage> chunkMap;
private final int chunkCacheSize;

public WorldStorage() {
this(DEFAULT_CHUNK_CACHE_SIZE);
}

public WorldStorage(int chunkCacheSize) {
this.chunkMap = new ConcurrentHashMap<>();
this.chunkCacheSize = chunkCacheSize;
}

public ChunkStorage getWorld(UUID worldUid) {
return chunkMap.computeIfAbsent(worldUid, k -> new ChunkStorage());
return chunkMap.computeIfAbsent(worldUid, k -> new ChunkStorage(chunkCacheSize));
}

public int getChunkCacheSize() {
return chunkCacheSize;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;

/**
* Thread-safe tracker for addon region scans in progress.
*/
public class AddonScanTracker {

private final Set<String> tracker;
Expand All @@ -16,11 +19,26 @@ public void add(String key) {
this.tracker.add(key);
}

/**
* Atomically adds key if not present. Returns true if added, false if already exists.
*/
public boolean tryAdd(String key) {
return this.tracker.add(key);
}

public boolean isQueued(String key) {
return this.tracker.contains(key);
}

public void remove(String key) {
this.tracker.remove(key);
}

public int size() {
return this.tracker.size();
}

public void clear() {
this.tracker.clear();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,6 @@ public boolean set(Long obj, boolean queued) {

@Override
public boolean isQueued(Long obj) {
return false;
return queuedChunks.contains(obj);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ public class Settings {
public final int REDSTONE_UPDATE_AGGREGATE_TICKS;
public final int REDSTONE_UPDATE_AGGREGATE_SIZE;
public final boolean REDSTONE_UPDATE_LIMITER_BLOCK_OUTSIDE_REGION;
public final int CHUNK_CACHE_MAX_SIZE;
public final int ADDON_CACHE_MAX_SIZE;
public final long ADDON_CACHE_TTL_MINUTES;

/**
* Constructs a new Settings object from the given YamlParser.
Expand All @@ -74,6 +77,10 @@ public Settings(InsightsPlugin plugin, YamlParser parser) {
CHUNK_SCANS_MODE = parser.getEnum("settings.chunk-scans.mode", ChunkScanMode.ALWAYS);
CHUNK_SCANS_PLAYER_TRACKER_INTERVAL_TICKS = parser.getInt("settings.chunk-scans.player-tracker-interval-ticks", 5, 1, Integer.MAX_VALUE);

CHUNK_CACHE_MAX_SIZE = parser.getInt("settings.chunk-cache.max-size", 5000, 100, Integer.MAX_VALUE);
ADDON_CACHE_MAX_SIZE = parser.getInt("settings.addon-cache.max-size", 500, 1, Integer.MAX_VALUE);
ADDON_CACHE_TTL_MINUTES = parser.getInt("settings.addon-cache.ttl-minutes", 10, 1, Integer.MAX_VALUE);

NOTIFICATION_TYPE = parser.getEnum("settings.notification.type", NotificationType.BOSSBAR);

NOTIFICATION_BOSSBAR_COLOR = parser.getEnum("settings.notification.bossbar.color", BossBar.Color.BLUE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,15 @@ private Optional<Storage> handleAddonAddition(
AddonStorage addonStorage = plugin.getAddonStorage();
Optional<Storage> storageOptional = addonStorage.get(key);
if (storageOptional.isEmpty()) {
// Use tryAdd to atomically check if a scan is already in progress
if (!plugin.getAddonScanTracker().tryAdd(key)) {
// A scan is already in progress, but we need to remove the tracker
// since scanRegion won't be called
return Optional.empty();
}
// Remove the tracker since scanRegion will add it again
plugin.getAddonScanTracker().remove(key);

// Notify the user scan started
if (plugin.getSettings().canReceiveAreaScanNotifications(player)) {
plugin.getMessages().getMessage(Messages.Key.AREA_SCAN_STARTED).addTemplates(
Expand Down Expand Up @@ -352,8 +361,14 @@ protected void handleRemoval(Player player, Location location, ScanObject<?> ite
}

private void scanRegion(Player player, Region region, Consumer<Storage> storageConsumer) {
String key = region.getKey();

// Use tryAdd to atomically prevent duplicate scans for the same region
if (!plugin.getAddonScanTracker().tryAdd(key)) {
return;
}

// Submit the cuboid for scanning
plugin.getAddonScanTracker().add(region.getAddon());
List<ChunkPart> chunkParts = region.toChunkParts();
ScanTask.scan(
plugin,
Expand All @@ -365,10 +380,10 @@ private void scanRegion(Player player, Region region, Consumer<Storage> storageC
DistributionStorage::new,
(storage, loc, acc) -> storage.mergeRight(acc),
storage -> {
plugin.getAddonScanTracker().remove(region.getAddon());
plugin.getAddonScanTracker().remove(key);

// Store the cuboid
plugin.getAddonStorage().put(region.getKey(), storage);
plugin.getAddonStorage().put(key, storage);

// Give the result back to the consumer
storageConsumer.accept(storage);
Expand Down
Loading