Allow deleting entities from strong load chunk

This commit is contained in:
MoePus committed 2026-08-18 17:42:12 +09:00
1 parent da5293c880
commit 52db9920b3
18 files changed
+988 -118

No files matched your search

@@ -3,6 +3,7 @@ package com.moepus.serverwarashi.client.heatmap;
import com.moepus.serverwarashi.api.client.map.MapViewport;
import com.moepus.serverwarashi.common.modules.performance.report.PerfReportData;
import com.moepus.serverwarashi.common.modules.performance.report.ChunkPerformanceRisk;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkSnapshot;
import com.moepus.serverwarashi.common.modules.weakload.WeakEntityType;
import net.minecraft.world.level.ChunkPos;
@@ -17,16 +18,14 @@ import java.util.Map;
import java.util.Set;
public final class ChunkHeatmapModel {
static final double MSPT_THRESHOLD = 0.5D;
static final double MAX_THRESHOLD = 3.0D;
private static final double LOG_TWO = Math.log(2.0D);
static final double MSPT_THRESHOLD = ChunkPerformanceRisk.MSPT_THRESHOLD;
static final double MAX_THRESHOLD = ChunkPerformanceRisk.MAX_THRESHOLD;
private static final int MAX_LOADER_QUADS = 16_384;
private static final int MAX_LOADER_ASSOCIATIONS = 65_536;
private static final int MAX_SOURCES_PER_CHUNK = 4;
private static final HeatmapColorStop LOADER_GREEN = HeatmapRiskScale.GREEN.withAlpha(36);
private static final HeatmapColorStop LOADER_CYAN = new HeatmapColorStop(48, 45, 220, 235);
static final int INACTIVE_LOADER_COLOR = 0x385C6268;
private static final double MAX_BURST_CONTRIBUTION = HeatmapRiskScale.RED_CONTRIBUTION - 0.25D;
private ChunkHeatmapModel() {
}
@@ -212,11 +211,7 @@ public final class ChunkHeatmapModel {
}
static double severity(PerfReportData.Metric metric) {
double normalizedMspt = metric.mspt() / MSPT_THRESHOLD;
double burst = Math.max(0.0D, metric.max() - metric.mspt()) / MAX_THRESHOLD;
double burstContribution = Math.min(
MAX_BURST_CONTRIBUTION, Math.log1p(burst) / LOG_TWO);
return normalizedMspt + burstContribution;
return ChunkPerformanceRisk.severity(metric);
}
static int color(PerfReportData.Metric metric) {
@@ -60,20 +60,18 @@ public final class MapContextMenuService {
Objects.requireNonNull(clients, "clients");
List<MapMenuItem> items = new ArrayList<>();
MenuContext context = new MenuContext(target, state, clients);
addWeakClearItem(items, context);
addEntityClearItem(items, context);
addLoaderItems(items, context);
return List.copyOf(items);
}
private static void addWeakClearItem(List<MapMenuItem> items,
MenuContext context) {
WeakChunkSnapshot.ChunkEntry chunk = context.state().weakChunk();
private static void addEntityClearItem(List<MapMenuItem> items,
MenuContext context) {
ClientActions clients = context.clients();
if (!clients.canClearWeakChunk() || chunk == null
|| chunk.entityCount() < WeakChunkSnapshot.ENTITY_RED_THRESHOLD) {
WeakEntityType type = clearTarget(context);
if (!clients.canClearWeakChunk() || type == null) {
return;
}
WeakEntityType type = chunk.topTypes(1).getFirst().getKey();
PendingClear selection = new PendingClear(context.target(), type);
boolean confirming = selection.equals(pendingClear);
String key = confirming
@@ -84,6 +82,43 @@ public final class MapContextMenuService {
() -> selectWeakClear(selection, confirming, clients)));
}
private static WeakEntityType clearTarget(MenuContext context) {
WeakChunkSnapshot.ChunkEntry weak = context.state().weakChunk();
if (weak != null) {
return weak.topTypes(1).stream()
.filter(entry -> entry.getValue() >= WeakChunkSnapshot.ENTITY_CLEAR_THRESHOLD)
.map(Map.Entry::getKey)
.findFirst().orElse(null);
}
PerfReportData report = context.state().report();
if (report == null) {
return null;
}
return report.chunks().stream()
.filter(chunk -> matchesStrongChunk(chunk, context.target()))
.flatMap(chunk -> chunk.topEntities().stream())
.filter(object -> object.count() >= WeakChunkSnapshot.ENTITY_CLEAR_THRESHOLD)
.map(MapContextMenuService::clearType)
.filter(Objects::nonNull)
.findFirst().orElse(null);
}
private static boolean matchesStrongChunk(PerfReportData.ChunkEntry chunk, MapTarget target) {
return chunk.dimension().equals(target.dimension().toString())
&& chunk.chunkX() == target.chunkX()
&& chunk.chunkZ() == target.chunkZ();
}
private static WeakEntityType clearType(PerfReportData.ObjectCount object) {
WeakEntityType.Kind kind = switch (object.detailKind()) {
case ITEM -> WeakEntityType.Kind.ITEM;
case ENTITY -> WeakEntityType.Kind.ENTITY;
case BLOCK, NONE -> null;
};
return kind == null || ResourceLocation.tryParse(object.detailId()) == null
? null : new WeakEntityType(kind, object.detailId());
}
private static void selectWeakClear(PendingClear selection,
boolean confirming,
ClientActions clients) {
@@ -0,0 +1,24 @@
package com.moepus.serverwarashi.common.modules.performance.report;
public final class ChunkPerformanceRisk {
public static final double MSPT_THRESHOLD = 0.5D;
public static final double MAX_THRESHOLD = 3.0D;
public static final double RED_THRESHOLD = 4.0D;
private static final double LOG_TWO = Math.log(2.0D);
private static final double MAX_BURST_CONTRIBUTION = RED_THRESHOLD - 0.25D;
private ChunkPerformanceRisk() {
}
public static boolean isRedOrHotter(PerfReportData.Metric metric) {
return metric != null && severity(metric) >= RED_THRESHOLD;
}
public static double severity(PerfReportData.Metric metric) {
double normalizedMspt = metric.mspt() / MSPT_THRESHOLD;
double burst = Math.max(0.0D, metric.max() - metric.mspt()) / MAX_THRESHOLD;
double burstContribution = Math.min(
MAX_BURST_CONTRIBUTION, Math.log1p(burst) / LOG_TWO);
return normalizedMspt + burstContribution;
}
}
@@ -85,6 +85,13 @@ public record PerfReportData(long reportId,
omittedLoaderSources, chunks, tickables, owners, nextLoaderSources);
}
public PerfReportData withChunks(List<ChunkEntry> nextChunks) {
return chunks.equals(nextChunks) ? this : new PerfReportData(
reportId, serverMspt, elapsedMillis, generatedAtEpochMillis, tickCount, totalSamples,
intervalMicros, omittedChunks, omittedTickables, omittedOwnerSources,
omittedLoaderSources, nextChunks, tickables, owners, loaderSources);
}
public record Metric(double mspt, double max, long samples) {
}
@@ -156,6 +163,12 @@ public record PerfReportData(long reportId,
topEntities = List.copyOf(topEntities);
topBlockEntities = List.copyOf(topBlockEntities);
}
public ChunkEntry withEntities(int nextEntityCount, List<ObjectCount> nextTopEntities) {
return entityCount == nextEntityCount && topEntities.equals(nextTopEntities)
? this : new ChunkEntry(dimension, chunkX, chunkZ,
nextEntityCount, blockEntityCount, nextTopEntities, topBlockEntities, metric);
}
}
public record ObjectCount(String className,
@@ -14,14 +14,13 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
public final class WeakChunkCollector {
public static final int CHUNKS_PER_TICK = 64;
private WeakChunkCollector() {
public final class ChunkEntityCollector {
private ChunkEntityCollector() {
}
public static ScanResult scan(ServerLevel level, ScanCursor cursor, int chunkBudget) {
public static ScanResult scanWeak(ServerLevel level, ScanCursor cursor, int chunkBudget) {
if (chunkBudget <= 0) {
throw new IllegalArgumentException("Chunk budget must be positive");
}
@@ -56,7 +55,7 @@ public final class WeakChunkCollector {
}
continue;
}
WeakChunkSnapshot.ChunkEntry entry = collectChunk(storage, dimension, chunkPos);
WeakChunkSnapshot.ChunkEntry entry = collectTrackedChunk(storage, dimension, chunkPos);
if (entry != null) {
warnings.add(entry);
}
@@ -70,48 +69,177 @@ public final class WeakChunkCollector {
return new ScanResult(List.copyOf(warnings), cursor.sweepId(), false, scanned, false);
}
public static WeakChunkSnapshot.ChunkEntry inspect(ServerLevel level, long chunkPos) {
return collectChunk(WeakChunkEntityAccess.storage(level),
public static WeakChunkSnapshot.ChunkEntry inspectTracked(ServerLevel level, long chunkPos) {
return collectTrackedChunk(WeakChunkEntityAccess.storage(level),
level.dimension().location().toString(), chunkPos);
}
private static WeakChunkSnapshot.ChunkEntry collectChunk(EntitySectionStorage<Entity> storage,
String dimension,
long chunkPos) {
int entityCount = trackedEntityCount(storage, chunkPos);
static WeakChunkSnapshot.ChunkEntry inspectAccessible(ServerLevel level, long chunkPos) {
return inspect(level, chunkPos, Visibility::isAccessible).toWeakEntry();
}
public static ChunkEntitySummary inspectTicking(ServerLevel level, long chunkPos) {
return inspect(level, chunkPos, Visibility::isTicking);
}
public static VisibilitySummaries inspectByVisibility(ServerLevel level, long chunkPos) {
EntitySectionStorage<Entity> storage = WeakChunkEntityAccess.storage(level);
String dimension = level.dimension().location().toString();
List<EntitySection<Entity>> tracked = new ArrayList<>();
List<EntitySection<Entity>> ticking = new ArrayList<>();
storage.getExistingSectionsInChunk(chunkPos).forEach(section -> {
if (section.getStatus() == Visibility.TRACKED) {
tracked.add(section);
} else if (section.getStatus().isTicking()) {
ticking.add(section);
}
});
return new VisibilitySummaries(
summarize(tracked, dimension, chunkPos),
summarize(ticking, dimension, chunkPos));
}
private static WeakChunkSnapshot.ChunkEntry collectTrackedChunk(
EntitySectionStorage<Entity> storage, String dimension, long chunkPos) {
List<EntitySection<Entity>> sections = sections(
storage, chunkPos, visibility -> visibility == Visibility.TRACKED);
int entityCount = entityCount(sections);
if (entityCount < WeakChunkSnapshot.ENTITY_WARNING_THRESHOLD) {
return null;
}
List<EntitySection<Entity>> sections = storage.getExistingSectionsInChunk(chunkPos)
.filter(section -> section.getStatus() == Visibility.TRACKED)
.toList();
Map<WeakEntityType, Integer> typeCounts = sampleTypes(
sections, dimension, chunkPos, entityCount);
return summarize(sections, dimension, chunkPos, entityCount).toWeakEntry();
}
private static ChunkEntitySummary inspect(ServerLevel level,
long chunkPos,
Predicate<Visibility> visibility) {
EntitySectionStorage<Entity> storage = WeakChunkEntityAccess.storage(level);
return summarize(sections(storage, chunkPos, visibility),
level.dimension().location().toString(), chunkPos);
}
private static ChunkEntitySummary summarize(List<EntitySection<Entity>> sections,
String dimension,
long chunkPos) {
return summarize(sections, dimension, chunkPos, entityCount(sections));
}
private static ChunkEntitySummary summarize(List<EntitySection<Entity>> sections,
String dimension,
long chunkPos,
int entityCount) {
ChunkPos position = new ChunkPos(chunkPos);
return new WeakChunkSnapshot.ChunkEntry(position.x, position.z, entityCount, typeCounts);
if (entityCount == 0) {
return new ChunkEntitySummary(position.x, position.z, 0, Map.of());
}
Map<WeakEntityType, TypeEstimate> typeCounts = sampleTypes(
sections, dimension, chunkPos, entityCount);
return new ChunkEntitySummary(position.x, position.z, entityCount, typeCounts);
}
private static int trackedEntityCount(EntitySectionStorage<Entity> storage, long chunkPos) {
private static int entityCount(List<EntitySection<Entity>> sections) {
return sections.stream().mapToInt(EntitySection::size).sum();
}
private static List<EntitySection<Entity>> sections(
EntitySectionStorage<Entity> storage,
long chunkPos,
Predicate<Visibility> visibility) {
return storage.getExistingSectionsInChunk(chunkPos)
.filter(section -> section.getStatus() == Visibility.TRACKED)
.mapToInt(EntitySection::size)
.sum();
.filter(section -> visibility.test(section.getStatus()))
.toList();
}
private static Map<WeakEntityType, Integer> sampleTypes(List<EntitySection<Entity>> sections,
String dimension,
long chunkPos,
int entityCount) {
private static Map<WeakEntityType, TypeEstimate> sampleTypes(
List<EntitySection<Entity>> sections,
String dimension,
long chunkPos,
int entityCount) {
int[] indices = StableEntitySampler.sampleIndices(entityCount, StableEntitySampler.SAMPLE_LIMIT,
StableEntitySampler.seed(dimension, chunkPos, entityCount));
Map<WeakEntityType, Integer> samples = new HashMap<>();
Map<WeakEntityType, MutableTypeSample> samples = new HashMap<>();
List<List<Entity>> entitySections = sections.stream()
.map(WeakChunkEntityAccess::entities)
.toList();
for (Entity entity : StableEntitySampler.sampleValues(entitySections, indices)) {
samples.merge(WeakEntityClassifier.typeOf(entity), 1, Integer::sum);
samples.computeIfAbsent(WeakEntityClassifier.typeOf(entity),
ignored -> new MutableTypeSample(entity)).count++;
}
Map<WeakEntityType, Integer> sampleCounts = new HashMap<>();
samples.forEach((type, sample) -> sampleCounts.put(type, sample.count));
Map<WeakEntityType, Integer> estimates = StableEntitySampler.estimateCounts(
sampleCounts, indices.length, entityCount);
Map<WeakEntityType, TypeEstimate> result = new HashMap<>();
estimates.forEach((type, count) -> {
MutableTypeSample sample = samples.get(type);
result.put(type, new TypeEstimate(count, sample.x, sample.y, sample.z));
});
return Map.copyOf(result);
}
public record ChunkEntitySummary(int chunkX,
int chunkZ,
int entityCount,
Map<WeakEntityType, TypeEstimate> typeEstimates) {
public ChunkEntitySummary {
if (entityCount < 0) {
throw new IllegalArgumentException("Entity count must be non-negative");
}
typeEstimates = Map.copyOf(typeEstimates);
long estimatedTotal = typeEstimates.values().stream()
.mapToLong(TypeEstimate::count)
.sum();
if (estimatedTotal != entityCount) {
throw new IllegalArgumentException("Type estimates must sum to the entity count");
}
}
public List<Map.Entry<WeakEntityType, TypeEstimate>> topTypes(int limit) {
if (limit < 0) {
throw new IllegalArgumentException("Top type limit must be non-negative");
}
return typeEstimates.entrySet().stream()
.sorted(Map.Entry.<WeakEntityType, TypeEstimate>comparingByValue(
java.util.Comparator.comparingInt(TypeEstimate::count))
.reversed()
.thenComparing(Map.Entry.comparingByKey()))
.limit(limit)
.toList();
}
public WeakChunkSnapshot.ChunkEntry toWeakEntry() {
if (entityCount < WeakChunkSnapshot.ENTITY_WARNING_THRESHOLD) {
return null;
}
Map<WeakEntityType, Integer> counts = new HashMap<>();
typeEstimates.forEach((type, estimate) -> counts.put(type, estimate.count()));
return new WeakChunkSnapshot.ChunkEntry(chunkX, chunkZ, entityCount, counts);
}
}
public record TypeEstimate(int count, int x, int y, int z) {
public TypeEstimate {
if (count <= 0) {
throw new IllegalArgumentException("Estimated type count must be positive");
}
}
}
public record VisibilitySummaries(ChunkEntitySummary tracked,
ChunkEntitySummary ticking) {
}
private static final class MutableTypeSample {
private int count;
private final int x;
private final int y;
private final int z;
private MutableTypeSample(Entity entity) {
x = entity.getBlockX();
y = entity.getBlockY();
z = entity.getBlockZ();
}
return StableEntitySampler.estimateCounts(samples, indices.length, entityCount);
}
public static final class ScanCursor {
@@ -21,8 +21,8 @@ import java.util.Set;
public final class WeakChunkClearJobs {
public static final int ENTITIES_PER_TICK = 1_000;
static final int SERVER_CLEAR_THRESHOLD = 100;
private static final int REMOVAL_BATCH_SIZE = 128;
private static final int VALIDATION_TOP_TYPES = 3;
private static final int ATTEMPT_PASSES = 2;
private static final ArrayDeque<Job> JOBS = new ArrayDeque<>();
private static final Set<JobKey> ACTIVE = new HashSet<>();
@@ -83,21 +83,27 @@ public final class WeakChunkClearJobs {
private static WeakChunkSnapshot.ChunkEntry validatedTarget(ServerLevel level,
long chunkPos,
WeakEntityType target) {
WeakChunkSnapshot.ChunkEntry warning = WeakChunkCollector.inspect(level, chunkPos);
WeakChunkSnapshot.ChunkEntry warning = ChunkEntityCollector.inspectAccessible(level, chunkPos);
return isEstimatedTarget(warning, target) ? warning : null;
}
static boolean isEstimatedTarget(WeakChunkSnapshot.ChunkEntry warning, WeakEntityType target) {
return warning != null
&& warning.entityCount() >= WeakChunkSnapshot.ENTITY_RED_THRESHOLD
&& warning.topTypes(VALIDATION_TOP_TYPES).stream()
.anyMatch(entry -> entry.getKey().equals(target));
&& warning.entityTypeCounts().getOrDefault(target, 0) >= SERVER_CLEAR_THRESHOLD;
}
static long attemptBudget(int initialEntityCount) {
return (long) initialEntityCount * ATTEMPT_PASSES + ENTITIES_PER_TICK;
}
static boolean isClearableVisibility(Visibility visibility) {
return visibility.isAccessible();
}
static boolean usesBatchRemoval(Visibility visibility) {
return visibility == Visibility.TRACKED;
}
private static BatchResult process(MinecraftServer server, Job job, int tickLimit) {
ServerLevel level = server.getLevel(job.key().dimension());
if (level == null) {
@@ -124,21 +130,39 @@ public final class WeakChunkClearJobs {
int limit) {
long sectionId = job.currentSectionId();
EntitySection<Entity> section = storage.getSection(sectionId);
if (section == null || section.getStatus() != Visibility.TRACKED) {
if (section == null || !isClearableVisibility(section.getStatus())) {
job.cursor().finishSection();
return 0;
}
SelectionSource source = new SelectionSource(section, job.target(), job.cursor());
Selection selection = select(source, Math.min(REMOVAL_BATCH_SIZE, limit));
WeakChunkBatchRemoval.Result result = selection.targets().isEmpty()
? WeakChunkBatchRemoval.Result.EMPTY
: WeakChunkBatchRemoval.clear(
new WeakChunkBatchRemoval.SectionRef(storage, sectionId, section), selection.targets());
var sectionRef = new WeakChunkBatchRemoval.SectionRef(storage, sectionId, section);
WeakChunkBatchRemoval.Result result = removeSelected(sectionRef, selection.targets());
job.cursor().recordRemovals(result.removed());
advanceCursor(job.cursor(), selection, currentEntities(storage, sectionId));
return result.attempted();
}
private static WeakChunkBatchRemoval.Result removeSelected(
WeakChunkBatchRemoval.SectionRef sectionRef,
List<WeakChunkBatchRemoval.Target> targets) {
if (targets.isEmpty()) {
return WeakChunkBatchRemoval.Result.EMPTY;
}
if (usesBatchRemoval(sectionRef.section().getStatus())) {
return WeakChunkBatchRemoval.clear(sectionRef, targets);
}
int removed = 0;
for (WeakChunkBatchRemoval.Target target : targets) {
Entity entity = target.entity();
if (!entity.isRemoved()) {
entity.discard();
removed += entity.isRemoved() ? 1 : 0;
}
}
return new WeakChunkBatchRemoval.Result(targets.size(), removed);
}
private static Selection select(SelectionSource source, int limit) {
// This selector is for removing a chunk's validated top entity types. Do not reuse it for
// sparse matches: non-matching entities are scanned without consuming the removal budget.
@@ -13,6 +13,7 @@ public record WeakChunkSnapshot(String dimension,
public static final int MAX_CHUNKS = 8;
public static final int MAX_SAMPLED_TYPES = 16;
public static final int ENTITY_WARNING_THRESHOLD = 50;
public static final int ENTITY_CLEAR_THRESHOLD = 200;
public static final int ENTITY_RED_THRESHOLD = 350;
public static final int ENTITY_PURPLE_THRESHOLD = 500;
@@ -0,0 +1,243 @@
package com.moepus.serverwarashi.common.network;
import com.moepus.serverwarashi.common.modules.performance.report.ChunkPerformanceRisk;
import com.moepus.serverwarashi.common.modules.performance.report.PerfReportData;
import com.moepus.serverwarashi.common.modules.performance.sampling.TickDetailKind;
import com.moepus.serverwarashi.common.modules.weakload.ChunkEntityCollector;
import com.moepus.serverwarashi.common.modules.weakload.ChunkEntityCollector.ChunkEntitySummary;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkClearJobs;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkSnapshot;
import com.moepus.serverwarashi.common.modules.weakload.WeakEntityType;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceKey;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
final class PerfChunkEntityScanQueue {
private static final int TOP_TYPE_LIMIT = 3;
private final LinkedHashSet<ChunkKey> deletionQueue = new LinkedHashSet<>();
private final LinkedHashSet<ChunkKey> reportQueue = new LinkedHashSet<>();
private final Map<ChunkKey, ChunkEntitySummary> cache = new HashMap<>();
private Set<ChunkKey> currentKeys = Set.of();
private Set<ChunkKey> currentRedKeys = Set.of();
private final Set<ChunkKey> forcedCurrentKeys = new HashSet<>();
private ReportVersion version;
private PerfReportData sourceReport;
private PerfReportData materializedReport;
PerfReportData accept(PerfReportData report) {
if (report == null) {
return null;
}
ReportVersion nextVersion = ReportVersion.from(report);
sourceReport = report;
if (!nextVersion.equals(version)) {
version = nextVersion;
currentKeys = indexKeys(report);
currentRedKeys = indexRedKeys(report);
forcedCurrentKeys.clear();
reportQueue.clear();
reportQueue.addAll(currentRedKeys);
retainCurrentCache();
}
materializedReport = materialize();
return materializedReport;
}
void prioritize(List<WeakChunkClearJobs.CompletedChunk> completed) {
for (WeakChunkClearJobs.CompletedChunk chunk : completed) {
ChunkKey key = new ChunkKey(chunk.dimension(), chunk.chunkPos());
deletionQueue.add(key);
reportQueue.remove(key);
}
}
ScanBatch scan(MinecraftServer server) {
return scan((key, deletion) -> scan(server, key, deletion));
}
ScanBatch scan(EntityScanner scanner) {
List<TrackedCorrection> trackedCorrections = new ArrayList<>();
Set<ChunkKey> scanned = new HashSet<>();
drainDeletionQueue(scanner, trackedCorrections, scanned);
drainReportQueue(scanner, trackedCorrections, scanned);
PerfReportData update = updatedReport();
return new ScanBatch(scanned.size(), trackedCorrections, update);
}
void clear() {
deletionQueue.clear();
reportQueue.clear();
cache.clear();
currentKeys = Set.of();
currentRedKeys = Set.of();
forcedCurrentKeys.clear();
version = null;
sourceReport = null;
materializedReport = null;
}
private void drainDeletionQueue(EntityScanner scanner,
List<TrackedCorrection> corrections,
Set<ChunkKey> scanned) {
while (!deletionQueue.isEmpty()) {
ChunkKey key = removeFirst(deletionQueue);
ScanValue value = scanner.scan(key, true);
scanned.add(key);
cache.put(key, value.ticking());
if (currentKeys.contains(key)) {
forcedCurrentKeys.add(key);
}
corrections.add(new TrackedCorrection(key, value.tracked()));
}
}
private void drainReportQueue(EntityScanner scanner,
List<TrackedCorrection> corrections,
Set<ChunkKey> scanned) {
while (!reportQueue.isEmpty()) {
ChunkKey key = removeFirst(reportQueue);
if (!scanned.add(key)) {
continue;
}
ScanValue value = scanner.scan(key, false);
cache.put(key, value.ticking());
corrections.add(new TrackedCorrection(key, value.tracked()));
}
}
private static ScanValue scan(MinecraftServer server, ChunkKey key, boolean deletion) {
ServerLevel level = server.getLevel(key.dimension());
if (level == null) {
return new ScanValue(emptySummary(key), null);
}
ChunkEntityCollector.VisibilitySummaries summaries =
ChunkEntityCollector.inspectByVisibility(level, key.chunkPos());
return new ScanValue(summaries.ticking(), summaries.tracked().toWeakEntry());
}
private PerfReportData updatedReport() {
PerfReportData next = materialize();
if (next == null || next.equals(materializedReport)) {
return null;
}
materializedReport = next;
return next;
}
private PerfReportData materialize() {
if (sourceReport == null) {
return null;
}
List<PerfReportData.ChunkEntry> chunks = sourceReport.chunks().stream()
.map(this::materializeChunk)
.toList();
return sourceReport.withChunks(chunks);
}
private PerfReportData.ChunkEntry materializeChunk(PerfReportData.ChunkEntry chunk) {
ChunkKey key = key(chunk);
if (key == null || (!currentRedKeys.contains(key) && !forcedCurrentKeys.contains(key))) {
return chunk;
}
ChunkEntitySummary summary = cache.get(key);
return summary == null ? chunk : chunk.withEntities(
summary.entityCount(), objectCounts(summary));
}
private static List<PerfReportData.ObjectCount> objectCounts(ChunkEntitySummary summary) {
return summary.topTypes(TOP_TYPE_LIMIT).stream().map(entry -> {
WeakEntityType type = entry.getKey();
ChunkEntityCollector.TypeEstimate estimate = entry.getValue();
TickDetailKind detailKind = type.kind() == WeakEntityType.Kind.ITEM
? TickDetailKind.ITEM : TickDetailKind.ENTITY;
return new PerfReportData.ObjectCount("", type.id(), detailKind, estimate.count(),
estimate.x(), estimate.y(), estimate.z());
}).toList();
}
private static Set<ChunkKey> indexKeys(PerfReportData report) {
Set<ChunkKey> result = new HashSet<>();
report.chunks().forEach(chunk -> addKey(result, chunk));
return Set.copyOf(result);
}
private static Set<ChunkKey> indexRedKeys(PerfReportData report) {
Set<ChunkKey> result = new LinkedHashSet<>();
report.chunks().stream()
.filter(chunk -> ChunkPerformanceRisk.isRedOrHotter(chunk.metric()))
.forEach(chunk -> addKey(result, chunk));
return Set.copyOf(result);
}
private static void addKey(Set<ChunkKey> target, PerfReportData.ChunkEntry chunk) {
ChunkKey key = key(chunk);
if (key != null) {
target.add(key);
}
}
private static ChunkKey key(PerfReportData.ChunkEntry chunk) {
ResourceLocation id = ResourceLocation.tryParse(chunk.dimension());
return id == null ? null : new ChunkKey(
ResourceKey.create(Registries.DIMENSION, id),
ChunkPos.asLong(chunk.chunkX(), chunk.chunkZ()));
}
private void retainCurrentCache() {
Set<ChunkKey> retained = new HashSet<>(currentKeys);
retained.addAll(deletionQueue);
cache.keySet().retainAll(retained);
}
private static ChunkEntitySummary emptySummary(ChunkKey key) {
ChunkPos position = new ChunkPos(key.chunkPos());
return new ChunkEntitySummary(position.x, position.z, 0, Map.of());
}
private static <T> T removeFirst(LinkedHashSet<T> values) {
T first = values.getFirst();
values.remove(first);
return first;
}
record ChunkKey(ResourceKey<Level> dimension, long chunkPos) {
}
record TrackedCorrection(ChunkKey key, WeakChunkSnapshot.ChunkEntry entry) {
}
record ScanBatch(int scannedChunks,
List<TrackedCorrection> trackedCorrections,
PerfReportData reportUpdate) {
ScanBatch {
trackedCorrections = List.copyOf(trackedCorrections);
}
}
record ScanValue(ChunkEntitySummary ticking, WeakChunkSnapshot.ChunkEntry tracked) {
}
private record ReportVersion(long reportId, long tickCount, long generatedAt) {
private static ReportVersion from(PerfReportData report) {
return new ReportVersion(
report.reportId(), report.tickCount(), report.generatedAtEpochMillis());
}
}
@FunctionalInterface
interface EntityScanner {
ScanValue scan(ChunkKey key, boolean deletion);
}
}
@@ -2,25 +2,31 @@ package com.moepus.serverwarashi.common.network;
import com.moepus.serverwarashi.common.modules.performance.report.PerfReportData;
import com.moepus.serverwarashi.common.modules.performance.report.PerfReportTextRenderer;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkClearJobs;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerPlayer;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.UUID;
public final class PerfReportDelivery {
private static final ViewerSubscriptions VIEWERS = new ViewerSubscriptions();
private static final PerfChunkEntityScanQueue ENTITY_SCANS = new PerfChunkEntityScanQueue();
private static final Set<UUID> ONE_SHOT_UPDATES = new HashSet<>();
private static long lastPushedTickCount = -1L;
private static boolean latestProfiling;
private PerfReportDelivery() {
}
public static void toCommandSource(CommandSourceStack source, PerfReportData report) {
if (source.getEntity() instanceof ServerPlayer player && WarashiNetwork.canSendPerfReport(player)) {
sendToPlayer(player, report, false);
ONE_SHOT_UPDATES.add(player.getUUID());
sendToPlayer(player, prepare(report, false), false);
return;
}
source.sendSuccess(() -> PerfReportTextRenderer.render(report), false);
@@ -29,7 +35,8 @@ public final class PerfReportDelivery {
public static void toPlayerOrConsole(MinecraftServer server, UUID playerId, PerfReportData report) {
ServerPlayer player = playerId == null ? null : server.getPlayerList().getPlayer(playerId);
if (player != null && WarashiNetwork.canSendPerfReport(player)) {
sendToPlayer(player, report, false);
ONE_SHOT_UPDATES.add(player.getUUID());
sendToPlayer(player, prepare(report, false), false);
return;
}
Component message = PerfReportTextRenderer.render(report);
@@ -50,7 +57,8 @@ public final class PerfReportDelivery {
if (deferred) {
return true;
}
WarashiNetwork.sendPerfReport(player, report, PerfReportPayload.Mode.OPEN, profiling);
WarashiNetwork.sendPerfReport(
player, prepare(report, profiling), PerfReportPayload.Mode.OPEN, profiling);
return true;
}
@@ -69,7 +77,8 @@ public final class PerfReportDelivery {
VIEWERS.setMap(playerId, true);
boolean paused = player.getServer() != null && player.getServer().isPaused();
if (!shouldDeferInitialReport(report, profiling, paused)) {
WarashiNetwork.sendPerfReport(player, report, PerfReportPayload.Mode.UPDATE, profiling);
WarashiNetwork.sendPerfReport(
player, prepare(report, profiling), PerfReportPayload.Mode.UPDATE, profiling);
}
}
@@ -85,14 +94,26 @@ public final class PerfReportDelivery {
return;
}
lastPushedTickCount = report.tickCount();
pushToViewers(server, report, true);
pushToViewers(server, prepare(report, true), true);
}
public static void publishNow(MinecraftServer server,
PerfReportData report,
boolean profiling) {
resetPushClock();
pushToViewers(server, report, profiling);
pushToViewers(server, prepare(report, profiling), profiling);
}
static PerfChunkEntityScanQueue.ScanBatch scanEntityCounts(
MinecraftServer server, List<WeakChunkClearJobs.CompletedChunk> completed) {
ENTITY_SCANS.prioritize(completed);
PerfChunkEntityScanQueue.ScanBatch batch = ENTITY_SCANS.scan(server);
if (batch.reportUpdate() != null) {
pushEntityCorrection(server, batch.reportUpdate());
} else {
ONE_SHOT_UPDATES.clear();
}
return batch;
}
static boolean hasNewSnapshot(PerfReportData report, long previousTickCount) {
@@ -105,15 +126,10 @@ public final class PerfReportDelivery {
}
for (UUID viewer : VIEWERS.recipients()) {
ServerPlayer player = server.getPlayerList().getPlayer(viewer);
if (player == null || !WarashiNetwork.canSendPerfReport(player)) {
if (player == null || !canReceiveLiveUpdate(player, viewer)) {
continue;
}
if (VIEWERS.isMapViewer(viewer) && !player.hasPermissions(2)) {
VIEWERS.setMap(viewer, false);
}
if (VIEWERS.isScreenViewer(viewer) || VIEWERS.isMapViewer(viewer)) {
WarashiNetwork.sendPerfReport(player, report, VIEWERS.nextMode(viewer), profiling);
}
WarashiNetwork.sendPerfReport(player, report, VIEWERS.nextMode(viewer), profiling);
}
}
@@ -123,6 +139,9 @@ public final class PerfReportDelivery {
public static void clearRuntimeState() {
VIEWERS.clear();
ENTITY_SCANS.clear();
ONE_SHOT_UPDATES.clear();
latestProfiling = false;
resetPushClock();
}
@@ -149,6 +168,36 @@ public final class PerfReportDelivery {
WarashiNetwork.sendPerfReport(player, report, mode, profiling);
}
private static PerfReportData prepare(PerfReportData report, boolean profiling) {
latestProfiling = profiling;
return ENTITY_SCANS.accept(report);
}
private static void pushEntityCorrection(MinecraftServer server, PerfReportData report) {
Set<UUID> recipients = VIEWERS.recipients();
recipients.addAll(ONE_SHOT_UPDATES);
for (UUID viewer : recipients) {
ServerPlayer player = server.getPlayerList().getPlayer(viewer);
if (player == null || !WarashiNetwork.canSendPerfReport(player)) {
continue;
}
boolean liveViewer = VIEWERS.retainPermittedLiveViewer(
viewer, player.hasPermissions(2));
if (!liveViewer && !ONE_SHOT_UPDATES.contains(viewer)) {
continue;
}
PerfReportPayload.Mode mode = liveViewer
? VIEWERS.nextMode(viewer) : PerfReportPayload.Mode.UPDATE;
WarashiNetwork.sendPerfReport(player, report, mode, latestProfiling);
}
ONE_SHOT_UPDATES.clear();
}
private static boolean canReceiveLiveUpdate(ServerPlayer player, UUID playerId) {
return WarashiNetwork.canSendPerfReport(player)
&& VIEWERS.retainPermittedLiveViewer(playerId, player.hasPermissions(2));
}
static boolean canSubscribeMap(boolean supportsChannel, boolean hasPermission) {
return supportsChannel && hasPermission;
}
@@ -197,6 +246,13 @@ public final class PerfReportDelivery {
return isScreenViewer(playerId) || isMapViewer(playerId);
}
boolean retainPermittedLiveViewer(UUID playerId, boolean hasMapPermission) {
if (!hasMapPermission) {
setMap(playerId, false);
}
return isLiveViewer(playerId);
}
PerfReportPayload.Mode nextMode(UUID playerId) {
return pendingScreenOpen.remove(playerId)
? PerfReportPayload.Mode.OPEN : PerfReportPayload.Mode.UPDATE;
@@ -1,7 +1,7 @@
package com.moepus.serverwarashi.common.network;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkClearJobs;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkCollector;
import com.moepus.serverwarashi.common.modules.weakload.ChunkEntityCollector;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkSnapshot;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel;
@@ -19,9 +19,11 @@ import java.util.UUID;
public final class WeakChunkDelivery {
static final int IDLE_SWEEP_COOLDOWN_TICKS = 200;
static final int BASE_CHUNK_SCAN_BUDGET = 64;
private static final Map<ServerLevel, ScanState> SCANNERS = new LinkedHashMap<>();
private static final Map<UUID, ServerLevel> VIEWER_LEVELS = new HashMap<>();
private static final Set<UUID> INITIAL_SYNC_PENDING = new HashSet<>();
private static int nextScannerIndex;
private WeakChunkDelivery() {
}
@@ -40,14 +42,17 @@ public final class WeakChunkDelivery {
updateLevels(server);
updateViewers(server);
publishInitialSnapshots();
publishCorrections(server, completed);
SCANNERS.forEach(WeakChunkDelivery::scanAndPublish);
PerfChunkEntityScanQueue.ScanBatch priority =
PerfReportDelivery.scanEntityCounts(server, completed);
publishCorrections(server, priority.trackedCorrections());
scanWeakChunks(remainingWeakBudget(priority.scannedChunks()));
}
public static void clearRuntimeState() {
SCANNERS.clear();
VIEWER_LEVELS.clear();
INITIAL_SYNC_PENDING.clear();
nextScannerIndex = 0;
}
private static void updateLevels(MinecraftServer server) {
@@ -112,12 +117,33 @@ public final class WeakChunkDelivery {
state.cursor.sweepId(), false, state.cache.currentEntries());
}
private static void scanAndPublish(ServerLevel level, ScanState state) {
if (!state.throttle.shouldScan(!state.viewers.isEmpty())) {
static int remainingWeakBudget(int priorityScans) {
return Math.max(0, BASE_CHUNK_SCAN_BUDGET - priorityScans);
}
private static void scanWeakChunks(int budget) {
if (budget == 0 || SCANNERS.isEmpty()) {
return;
}
WeakChunkCollector.ScanResult result = WeakChunkCollector.scan(
level, state.cursor, WeakChunkCollector.CHUNKS_PER_TICK);
List<Map.Entry<ServerLevel, ScanState>> scanners = List.copyOf(SCANNERS.entrySet());
int start = Math.floorMod(nextScannerIndex, scanners.size());
int remaining = budget;
for (int offset = 0; offset < scanners.size() && remaining > 0; offset++) {
Map.Entry<ServerLevel, ScanState> scanner =
scanners.get((start + offset) % scanners.size());
int scannersLeft = scanners.size() - offset;
int allocation = Math.max(1, remaining / scannersLeft);
remaining -= scanAndPublish(scanner.getKey(), scanner.getValue(), allocation);
}
nextScannerIndex = (start + 1) % scanners.size();
}
private static int scanAndPublish(ServerLevel level, ScanState state, int budget) {
if (!state.throttle.shouldScan(!state.viewers.isEmpty())) {
return 0;
}
ChunkEntityCollector.ScanResult result = ChunkEntityCollector.scanWeak(
level, state.cursor, budget);
if (result.emptyStorage()) {
state.cache.clearCurrent();
}
@@ -132,19 +158,22 @@ public final class WeakChunkDelivery {
} else if (result.emptyStorage()) {
state.throttle.onEmptyStorage(state.viewers.isEmpty());
}
return result.scannedChunks();
}
private static void publishCorrections(
MinecraftServer server, List<WeakChunkClearJobs.CompletedChunk> completed) {
for (WeakChunkClearJobs.CompletedChunk chunk : completed) {
ServerLevel level = server.getLevel(chunk.dimension());
MinecraftServer server,
List<PerfChunkEntityScanQueue.TrackedCorrection> corrections) {
for (PerfChunkEntityScanQueue.TrackedCorrection correction : corrections) {
ServerLevel level = server.getLevel(correction.key().dimension());
ScanState state = level == null ? null : SCANNERS.get(level);
if (state == null) {
continue;
}
WeakChunkSnapshot.ChunkEntry entry = WeakChunkCollector.inspect(level, chunk.chunkPos());
state.cache.correct(chunk.chunkPos(), entry);
sendCorrection(level, state.viewers, chunk.chunkPos(), entry);
long chunkPos = correction.key().chunkPos();
if (state.cache.correct(chunkPos, correction.entry())) {
sendCorrection(level, state.viewers, chunkPos, correction.entry());
}
}
}
@@ -212,14 +241,14 @@ public final class WeakChunkDelivery {
return snapshotDimension.equals(playerDimension) && subscribed && permitted;
}
private static void applyCorrection(Map<Long, WeakChunkSnapshot.ChunkEntry> cache,
long chunkPos,
WeakChunkSnapshot.ChunkEntry entry) {
private static boolean applyCorrection(Map<Long, WeakChunkSnapshot.ChunkEntry> cache,
long chunkPos,
WeakChunkSnapshot.ChunkEntry entry) {
if (entry == null) {
cache.remove(chunkPos);
} else {
cache.put(chunkPos, entry);
return cache.remove(chunkPos) != null;
}
WeakChunkSnapshot.ChunkEntry previous = cache.put(chunkPos, entry);
return !entry.equals(previous);
}
private static void merge(Map<Long, WeakChunkSnapshot.ChunkEntry> target,
@@ -234,7 +263,7 @@ public final class WeakChunkDelivery {
}
private static final class ScanState {
private final WeakChunkCollector.ScanCursor cursor = new WeakChunkCollector.ScanCursor();
private final ChunkEntityCollector.ScanCursor cursor = new ChunkEntityCollector.ScanCursor();
private final SweepCache cache = new SweepCache();
private final ScanThrottle throttle = new ScanThrottle();
private final List<ServerPlayer> viewers = new ArrayList<>();
@@ -293,9 +322,10 @@ public final class WeakChunkDelivery {
current.clear();
}
void correct(long chunkPos, WeakChunkSnapshot.ChunkEntry entry) {
applyCorrection(completed, chunkPos, entry);
applyCorrection(current, chunkPos, entry);
boolean correct(long chunkPos, WeakChunkSnapshot.ChunkEntry entry) {
boolean completedChanged = applyCorrection(completed, chunkPos, entry);
boolean currentChanged = applyCorrection(current, chunkPos, entry);
return completedChanged || currentChanged;
}
List<WeakChunkSnapshot.ChunkEntry> currentEntries() {
@@ -2,6 +2,8 @@ package com.moepus.serverwarashi.client.map;
import com.moepus.serverwarashi.api.client.map.MapMenuItem;
import com.moepus.serverwarashi.api.client.map.MapTarget;
import com.moepus.serverwarashi.common.modules.performance.report.PerfReportData;
import com.moepus.serverwarashi.common.modules.performance.sampling.TickDetailKind;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkSnapshot;
import com.moepus.serverwarashi.common.modules.weakload.WeakEntityType;
import com.moepus.serverwarashi.common.network.LoaderFreezeActionPayload;
@@ -53,7 +55,7 @@ class MapWeakChunkClearMenuTest {
}
@Test
void omitsUnavailableAndNonRedWeakChunkActionsAndClearResetsConfirmation() {
void omitsUnavailableAndBelowThresholdWeakActionsAndClearResetsConfirmation() {
MapTarget target = new MapTarget(OVERWORLD, 1, 2);
var unavailable = new RecordingClients(false, false);
assertTrue(items(target, chunk(ITEM), unavailable).isEmpty());
@@ -69,6 +71,45 @@ class MapWeakChunkClearMenuTest {
assertEquals("gui.serverwarashi.weak_clear", key(onlyItem(target, chunk(ITEM), clients)));
}
@Test
void weakChunkUsesTheLargestTypeCountRatherThanTheChunkTotal() {
MapTarget target = new MapTarget(OVERWORLD, 1, 2);
var clients = new RecordingClients(true, false);
var warning = new WeakChunkSnapshot.ChunkEntry(
1, 2, 500, Map.of(ITEM, 199, ENTITY, 301));
onlyItem(target, warning, clients).action().run();
onlyItem(target, warning, clients).action().run();
assertEquals(List.of(new WeakChunkClearPayload(
OVERWORLD.toString(), 1, 2, ENTITY)), clients.weakRequests);
}
@Test
void allowsStrongLoadedChunksByReceivedTypeCountWithoutCheckingHeatColor() {
MapTarget target = new MapTarget(OVERWORLD, 1, 2);
var clients = new RecordingClients(true, false);
PerfReportData report = strongReport(ITEM, WeakChunkSnapshot.ENTITY_CLEAR_THRESHOLD);
MapMenuItem first = onlyItem(target, null, report, clients);
assertEquals("gui.serverwarashi.weak_clear", key(first));
first.action().run();
onlyItem(target, null, report, clients).action().run();
assertEquals(List.of(new WeakChunkClearPayload(
OVERWORLD.toString(), 1, 2, ITEM)), clients.weakRequests);
}
@Test
void rejectsStrongLoadedChunksWhoseTopTypeIsBelowTheClearThreshold() {
MapTarget target = new MapTarget(OVERWORLD, 1, 2);
var clients = new RecordingClients(true, false);
PerfReportData report = strongReport(
ITEM, 1_000, WeakChunkSnapshot.ENTITY_CLEAR_THRESHOLD - 1);
assertTrue(items(target, null, report, clients).isEmpty());
}
private static MapMenuItem onlyItem(MapTarget target,
WeakChunkSnapshot.ChunkEntry chunk,
RecordingClients clients) {
@@ -77,18 +118,51 @@ class MapWeakChunkClearMenuTest {
return items.getFirst();
}
private static MapMenuItem onlyItem(MapTarget target,
WeakChunkSnapshot.ChunkEntry chunk,
PerfReportData report,
RecordingClients clients) {
List<MapMenuItem> items = items(target, chunk, report, clients);
assertEquals(1, items.size());
return items.getFirst();
}
private static List<MapMenuItem> items(MapTarget target,
WeakChunkSnapshot.ChunkEntry chunk,
RecordingClients clients) {
var state = new MapContextMenuService.MenuState(chunk, null, 0L);
return items(target, chunk, null, clients);
}
private static List<MapMenuItem> items(MapTarget target,
WeakChunkSnapshot.ChunkEntry chunk,
PerfReportData report,
RecordingClients clients) {
var state = new MapContextMenuService.MenuState(chunk, report, report == null ? 0L : report.reportId());
return MapContextMenuService.items(target, state, clients);
}
private static WeakChunkSnapshot.ChunkEntry chunk(WeakEntityType type) {
int count = WeakChunkSnapshot.ENTITY_RED_THRESHOLD;
int count = WeakChunkSnapshot.ENTITY_CLEAR_THRESHOLD;
return new WeakChunkSnapshot.ChunkEntry(1, 2, count, Map.of(type, count));
}
private static PerfReportData strongReport(WeakEntityType type, int entityCount) {
return strongReport(type, entityCount, entityCount);
}
private static PerfReportData strongReport(
WeakEntityType type, int entityCount, int typeCount) {
TickDetailKind detailKind = type.kind() == WeakEntityType.Kind.ITEM
? TickDetailKind.ITEM : TickDetailKind.ENTITY;
var object = new PerfReportData.ObjectCount(
"TestEntity", type.id(), detailKind, typeCount, 24, 64, 40);
var chunk = new PerfReportData.ChunkEntry(
OVERWORLD.toString(), 1, 2, entityCount, 0,
List.of(object), List.of(), new PerfReportData.Metric(1.0D, 2.0D, 10L));
return new PerfReportData(1.0D, 1_000L, 123_000L, 20L, 100L, 1_000,
0, 0, 0, List.of(chunk), List.of(), List.of());
}
private static String key(MapMenuItem item) {
return ((TranslatableContents) item.label().getContents()).getKey();
}
@@ -0,0 +1,22 @@
package com.moepus.serverwarashi.common.modules.performance.report;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class ChunkPerformanceRiskTest {
@Test
void identifiesRedByMetricWithoutClientClasses() {
assertFalse(ChunkPerformanceRisk.isRedOrHotter(
new PerfReportData.Metric(1.0D, 1.0D, 10L)));
assertTrue(ChunkPerformanceRisk.isRedOrHotter(
new PerfReportData.Metric(2.0D, 2.0D, 10L)));
}
@Test
void aSingleBurstRemainsBelowRed() {
assertFalse(ChunkPerformanceRisk.isRedOrHotter(
new PerfReportData.Metric(0.0D, 50.0D, 1L)));
}
}
@@ -0,0 +1,42 @@
package com.moepus.serverwarashi.common.modules.weakload;
import org.junit.jupiter.api.Test;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class ChunkEntityCollectorTest {
@Test
void restartKeepsSweepIdsMonotonic() {
var cursor = new ChunkEntityCollector.ScanCursor();
cursor.restart();
cursor.restart();
assertEquals(2L, cursor.sweepId());
}
@Test
void summaryPreservesEstimatedCountsAndRepresentativeCoordinates() {
WeakEntityType cow = new WeakEntityType(WeakEntityType.Kind.ENTITY, "minecraft:cow");
WeakEntityType item = new WeakEntityType(WeakEntityType.Kind.ITEM, "minecraft:stone");
var summary = new ChunkEntityCollector.ChunkEntitySummary(1, 2, 300, Map.of(
cow, new ChunkEntityCollector.TypeEstimate(100, 20, 64, 36),
item, new ChunkEntityCollector.TypeEstimate(200, 21, 65, 37)));
assertEquals(item, summary.topTypes(1).getFirst().getKey());
assertEquals(21, summary.topTypes(1).getFirst().getValue().x());
assertEquals(Map.of(cow, 100, item, 200), summary.toWeakEntry().entityTypeCounts());
}
@Test
void summaryBelowWarningThresholdDoesNotBecomeAWeakWarning() {
WeakEntityType cow = new WeakEntityType(WeakEntityType.Kind.ENTITY, "minecraft:cow");
var summary = new ChunkEntityCollector.ChunkEntitySummary(1, 2, 49, Map.of(
cow, new ChunkEntityCollector.TypeEstimate(49, 20, 64, 36)));
assertNull(summary.toWeakEntry());
}
}
@@ -1,5 +1,6 @@
package com.moepus.serverwarashi.common.modules.weakload;
import net.minecraft.world.level.entity.Visibility;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
@@ -17,22 +18,27 @@ class WeakChunkClearJobsTest {
private static final WeakEntityType FOURTH = entity("test:fourth");
@Test
void acceptsCurrentTopThreeButNotFourth() {
void acceptsAnyRequestedTypeAtTheServerThreshold() {
var warning = new WeakChunkSnapshot.ChunkEntry(0, 0, 1_000,
Map.of(FIRST, 400, SECOND, 300, THIRD, 200, FOURTH, 100));
Map.of(FIRST, 701, SECOND, 100, THIRD, 100, FOURTH, 99));
assertTrue(WeakChunkClearJobs.isEstimatedTarget(warning, FIRST));
assertTrue(WeakChunkClearJobs.isEstimatedTarget(warning, SECOND));
assertTrue(WeakChunkClearJobs.isEstimatedTarget(warning, THIRD));
assertFalse(WeakChunkClearJobs.isEstimatedTarget(warning, FOURTH));
}
@Test
void rejectsWarningBelowDangerThreshold() {
int belowDanger = WeakChunkSnapshot.ENTITY_RED_THRESHOLD - 1;
void acceptsServerThresholdAndRejectsOneBelowIt() {
int belowThreshold = WeakChunkClearJobs.SERVER_CLEAR_THRESHOLD - 1;
var warning = new WeakChunkSnapshot.ChunkEntry(
0, 0, belowDanger, Map.of(FIRST, belowDanger));
0, 0, belowThreshold, Map.of(FIRST, belowThreshold));
var clearable = new WeakChunkSnapshot.ChunkEntry(
0, 0, WeakChunkClearJobs.SERVER_CLEAR_THRESHOLD,
Map.of(FIRST, WeakChunkClearJobs.SERVER_CLEAR_THRESHOLD));
assertFalse(WeakChunkClearJobs.isEstimatedTarget(warning, FIRST));
assertTrue(WeakChunkClearJobs.isEstimatedTarget(clearable, FIRST));
}
@Test
@@ -45,6 +51,16 @@ class WeakChunkClearJobsTest {
assertEquals(4_294_968_294L, WeakChunkClearJobs.attemptBudget(Integer.MAX_VALUE));
}
@Test
void allowsTrackedAndTickingSectionsButNotHiddenSections() {
assertFalse(WeakChunkClearJobs.isClearableVisibility(Visibility.HIDDEN));
assertTrue(WeakChunkClearJobs.isClearableVisibility(Visibility.TRACKED));
assertTrue(WeakChunkClearJobs.isClearableVisibility(Visibility.TICKING));
assertTrue(WeakChunkClearJobs.usesBatchRemoval(Visibility.TRACKED));
assertFalse(WeakChunkClearJobs.usesBatchRemoval(Visibility.TICKING));
assertFalse(WeakChunkClearJobs.usesBatchRemoval(Visibility.HIDDEN));
}
@Test
void restoresOnlyWhenManagerDidNotEnterAndEntityHasNoManagedLocation() {
assertEquals(WeakChunkBatchRemoval.RestoreAction.RESTORE,
@@ -1,17 +0,0 @@
package com.moepus.serverwarashi.common.modules.weakload;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class WeakChunkCollectorTest {
@Test
void restartKeepsSweepIdsMonotonic() {
var cursor = new WeakChunkCollector.ScanCursor();
cursor.restart();
cursor.restart();
assertEquals(2L, cursor.sweepId());
}
}
@@ -0,0 +1,146 @@
package com.moepus.serverwarashi.common.network;
import com.moepus.serverwarashi.common.modules.performance.report.PerfReportData;
import com.moepus.serverwarashi.common.modules.performance.sampling.TickDetailKind;
import com.moepus.serverwarashi.common.modules.weakload.ChunkEntityCollector;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkClearJobs;
import com.moepus.serverwarashi.common.modules.weakload.WeakChunkSnapshot;
import com.moepus.serverwarashi.common.modules.weakload.WeakEntityType;
import net.minecraft.world.level.ChunkPos;
import net.minecraft.world.level.Level;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
class PerfChunkEntityScanQueueTest {
private static final WeakEntityType COW =
new WeakEntityType(WeakEntityType.Kind.ENTITY, "minecraft:cow");
@Test
void scansOnlyRedChunksAndPreservesOtherReportData() {
var queue = new PerfChunkEntityScanQueue();
PerfReportData.Metric red = new PerfReportData.Metric(2.0D, 2.0D, 10L);
PerfReportData report = report(20L, List.of(
chunk(1, 2, 7, 4, red),
chunk(3, 4, 9, 5, new PerfReportData.Metric(1.0D, 1.0D, 8L))));
List<PerfChunkEntityScanQueue.ChunkKey> scanned = new ArrayList<>();
queue.accept(report);
PerfChunkEntityScanQueue.ScanBatch batch = queue.scan((key, deletion) -> {
scanned.add(key);
return new PerfChunkEntityScanQueue.ScanValue(summary(key, 300), null);
});
assertEquals(1, batch.scannedChunks());
assertEquals(1, scanned.size());
assertEquals(1, batch.trackedCorrections().size());
assertNull(batch.trackedCorrections().getFirst().entry());
PerfReportData.ChunkEntry corrected = batch.reportUpdate().chunks().getFirst();
assertEquals(300, corrected.entityCount());
assertEquals(300, corrected.topEntities().getFirst().count());
assertEquals(4, corrected.blockEntityCount());
assertEquals(red, corrected.metric());
assertEquals(9, batch.reportUpdate().chunks().get(1).entityCount());
}
@Test
void deletionRefreshOverridesAndDeduplicatesAQueuedRedScan() {
var queue = new PerfChunkEntityScanQueue();
queue.accept(report(20L, List.of(chunk(1, 2, 7, 0,
new PerfReportData.Metric(2.0D, 2.0D, 10L)))));
long position = ChunkPos.asLong(1, 2);
queue.prioritize(List.of(new WeakChunkClearJobs.CompletedChunk(Level.OVERWORLD, position)));
List<Boolean> deletionFlags = new ArrayList<>();
PerfChunkEntityScanQueue.ScanBatch batch = queue.scan((key, deletion) -> {
deletionFlags.add(deletion);
WeakChunkSnapshot.ChunkEntry tracked = new WeakChunkSnapshot.ChunkEntry(
1, 2, 200, Map.of(COW, 200));
return new PerfChunkEntityScanQueue.ScanValue(summary(key, 250), tracked);
});
assertEquals(1, batch.scannedChunks());
assertEquals(List.of(true), deletionFlags);
assertEquals(1, batch.trackedCorrections().size());
assertEquals(250, batch.reportUpdate().chunks().getFirst().entityCount());
}
@Test
void aNewReportDropsStaleOrdinaryTasksButReusesCachedCounts() {
var queue = new PerfChunkEntityScanQueue();
PerfReportData.Metric red = new PerfReportData.Metric(2.0D, 2.0D, 10L);
queue.accept(report(20L, List.of(chunk(1, 2, 7, 0, red))));
queue.scan((key, deletion) ->
new PerfChunkEntityScanQueue.ScanValue(summary(key, 300), null));
PerfReportData cached = queue.accept(report(40L, List.of(chunk(1, 2, 8, 0, red))));
queue.accept(report(60L, List.of(chunk(3, 4, 9, 0, red))));
List<PerfChunkEntityScanQueue.ChunkKey> scanned = new ArrayList<>();
queue.scan((key, deletion) -> {
scanned.add(key);
return new PerfChunkEntityScanQueue.ScanValue(summary(key, 400), null);
});
assertEquals(300, cached.chunks().getFirst().entityCount());
assertEquals(1, scanned.size());
assertEquals(ChunkPos.asLong(3, 4), scanned.getFirst().chunkPos());
}
@Test
void redThresholdDoesNotIncludeAnOrangeChunk() {
var queue = new PerfChunkEntityScanQueue();
queue.accept(report(20L, List.of(chunk(1, 2, 300, 0,
new PerfReportData.Metric(1.0D, 1.0D, 10L)))));
PerfChunkEntityScanQueue.ScanBatch batch = queue.scan((key, deletion) -> {
throw new AssertionError("Orange chunks must not be scanned");
});
assertEquals(0, batch.scannedChunks());
assertNull(batch.reportUpdate());
}
@Test
void drainsEveryRedChunkEvenWhenTheQueueExceedsTheBaseBudget() {
var queue = new PerfChunkEntityScanQueue();
PerfReportData.Metric red = new PerfReportData.Metric(2.0D, 2.0D, 10L);
List<PerfReportData.ChunkEntry> chunks = new ArrayList<>();
for (int index = 0; index < 70; index++) {
chunks.add(chunk(index, 0, 200, 0, red));
}
queue.accept(report(20L, chunks));
PerfChunkEntityScanQueue.ScanBatch batch = queue.scan((key, deletion) ->
new PerfChunkEntityScanQueue.ScanValue(summary(key, 200), null));
assertEquals(70, batch.scannedChunks());
assertEquals(0, WeakChunkDelivery.remainingWeakBudget(batch.scannedChunks()));
}
private static PerfReportData report(long tickCount, List<PerfReportData.ChunkEntry> chunks) {
return new PerfReportData(7L, 1.0D, 1_000L, tickCount * 50L, tickCount, 100L, 1_000,
0, 0, 0, 0, chunks, List.of(), List.of(), List.of());
}
private static PerfReportData.ChunkEntry chunk(
int x, int z, int entities, int blockEntities, PerfReportData.Metric metric) {
var sampled = new PerfReportData.ObjectCount(
"Cow", COW.id(), TickDetailKind.ENTITY, entities, x * 16, 64, z * 16);
return new PerfReportData.ChunkEntry(Level.OVERWORLD.location().toString(), x, z,
entities, blockEntities, List.of(sampled), List.of(), metric);
}
private static ChunkEntityCollector.ChunkEntitySummary summary(
PerfChunkEntityScanQueue.ChunkKey key, int count) {
ChunkPos position = new ChunkPos(key.chunkPos());
var estimate = new ChunkEntityCollector.TypeEstimate(
count, position.getMiddleBlockX(), 64, position.getMiddleBlockZ());
return new ChunkEntityCollector.ChunkEntitySummary(
position.x, position.z, count, Map.of(COW, estimate));
}
}
@@ -69,6 +69,22 @@ class PerfReportDeliveryTest {
assertEquals(Set.of(screen, both), subscriptions.recipients());
}
@Test
void revokedMapPermissionDropsOnlyTheMapSubscription() {
UUID mapOnly = UUID.randomUUID();
UUID screenAndMap = UUID.randomUUID();
var subscriptions = new PerfReportDelivery.ViewerSubscriptions();
subscriptions.setMap(mapOnly, true);
subscriptions.openScreen(screenAndMap);
subscriptions.setMap(screenAndMap, true);
assertFalse(subscriptions.retainPermittedLiveViewer(mapOnly, false));
assertFalse(subscriptions.isMapViewer(mapOnly));
assertTrue(subscriptions.retainPermittedLiveViewer(screenAndMap, false));
assertFalse(subscriptions.isMapViewer(screenAndMap));
assertTrue(subscriptions.isScreenViewer(screenAndMap));
}
private static PerfReportData reportAtTick(long tickCount) {
return new PerfReportData(10.0D, 1_000L, 123_000L, tickCount, 10L, 1_000,
0, 0, 0, java.util.List.of(), java.util.List.of(), java.util.List.of());
@@ -13,6 +13,14 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WeakChunkDeliveryTest {
@Test
void priorityScansConsumeOnlyTheWeakBudgetAndMayExceedTheBase() {
assertEquals(64, WeakChunkDelivery.remainingWeakBudget(0));
assertEquals(1, WeakChunkDelivery.remainingWeakBudget(63));
assertEquals(0, WeakChunkDelivery.remainingWeakBudget(64));
assertEquals(0, WeakChunkDelivery.remainingWeakBudget(100));
}
@Test
void sendsOnlyToSubscribedPermittedPlayersStillInSnapshotDimension() {
assertTrue(WeakChunkDelivery.shouldSend(
@@ -46,15 +54,29 @@ class WeakChunkDeliveryTest {
cache.complete(0L, 10L);
cache.mergeCurrent(List.of(chunk(1, 2, 60)));
cache.correct(ChunkPos.asLong(1, 2), chunk(1, 2, 80));
assertTrue(cache.correct(ChunkPos.asLong(1, 2), chunk(1, 2, 80)));
assertEquals(List.of(chunk(1, 2, 80)), cache.completedEntries());
assertEquals(List.of(chunk(1, 2, 80)), cache.currentEntries());
cache.correct(ChunkPos.asLong(1, 2), null);
assertTrue(cache.correct(ChunkPos.asLong(1, 2), null));
assertTrue(cache.completedEntries().isEmpty());
assertTrue(cache.currentEntries().isEmpty());
}
@Test
void correctionReportsOnlyActualCacheChanges() {
var cache = new WeakChunkDelivery.SweepCache();
long position = ChunkPos.asLong(1, 2);
WeakChunkSnapshot.ChunkEntry entry = chunk(1, 2, 50);
assertFalse(cache.correct(position, null));
assertTrue(cache.correct(position, entry));
assertFalse(cache.correct(position, entry));
assertTrue(cache.correct(position, chunk(1, 2, 60)));
assertTrue(cache.correct(position, null));
assertFalse(cache.correct(position, null));
}
@Test
void emptyStorageDiscardsPartialSweepBeforeCompletion() {
var cache = new WeakChunkDelivery.SweepCache();