PaperMC / PaperMC/Paper

World.unloadChunk(x, z, false) cumulatively persists entities to disk

Open
#14,188 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: needs triage version: 26.2
Dominant language
Java
Stars
12.7k
Forks
3.5k
Avg merge
3d 13h
Merged PRs (30d)
11

Description

Expected behavior

Calling World.unloadChunk(x, z, false) on a chunk should leave that chunk's data as if the plugin had never touched it. No terrain changes and no entity changes should be persisted for that unload.

Observed/Actual behavior

save=false only suppresses the terrain write. Entity data is written unconditionally, regardless of the flag, and — for a chunk whose entities aren't yet backed by a completed disk read (i.e. entities from a chunk that was just generated) — that write merges with whatever's already on disk instead of replacing it.

Net effect: a plugin that generates a chunk for its own purposes (map rendering, LOD building, pregeneration, analysis) and calls unloadChunk(x, z, false) to clean up gets only half of what it asked for. Terrain regeneration is idempotent, so this is invisible under normal testing. Entity regeneration is not: worldgen mob spawning (NoiseBasedChunkGenerator.spawnOriginalMobs) seeds its RNG from setDecorationSeed(worldSeed, chunkX, chunkZ), so regenerating a chunk N times reproduces the same mobs at the same coordinates with the same attributes N times, differing only in UUID. Each of those N generations gets merged onto the last, so N regenerate/discard cycles silently persist N identical copies of every worldgen mob in that chunk.

I hit this in production via a plugin (Distant Horizons Support) that pregenerated LOD data across a large area, loading and discarding chunks it needed along the way. One pregeneration run left 50,716 duplicate mobs (a third of the world's entity count) in a 256-chunk-radius disc, with up to 105 copies of a single spawn.

Root cause, in the actual unload path:

CraftWorld.unloadChunk0 (paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java:460):

private boolean unloadChunk0(int x, int z, boolean save) {
    ...
    if (!save) {
        chunk.tryMarkSaved(); // ChunkAccess.tryMarkSaved(): this.unsaved = false
    }
    this.unloadChunkRequest(x, z);
    ...
}

tryMarkSaved() (ChunkAccess.java:334) only clears the dirty flag on the terrain chunk (and its PDC). In the actual unload, NewChunkHolder.unloadStage2 (ca/spottedleaf/moonrise/patches/chunk_system/scheduling/NewChunkHolder.java:869):

// unload chunk data
if (!shouldLevelChunkNotSave) {
    this.saveChunk(chunk, true, chunkWrite);   // gated on chunk.isUnsaved() - correctly skipped
}
...
// unload entity data
if (entityChunk != null) {
    this.saveEntities(entityChunk, true, entityWrite);   // unconditional - no flag consulted
    ...
}

Terrain is correctly gated and skipped. Entities are saved regardless. The periodic-save path is equally blunt (NewChunkHolder.save, ~line 1812): canSaveChunk checks chunk.isUnsaved(); canSaveEntities is just entities != null — no dirty check, no flag check at all.

Why it accumulates rather than just leaking once: NewChunkHolder.saveEntities (line 1886), for a transient entity chunk (not yet backed by a completed disk read — exactly the state of freshly-generated worldgen entities), merges rather than replaces:

final Completable<CompoundTag> mergeFrom = MoonriseRegionFileIO.loadDataAsync(
    this.world, this.chunkX, this.chunkZ, MoonriseRegionFileIO.RegionFileType.ENTITY_DATA, false, Priority.NORMAL
);
final Completable<CompoundTag> toWrite = mergeFrom.handle((onDisk, thr) -> {
    ...
    ChunkEntitySlices.copyEntities(onDisk, save);
    return save;
});

ChunkEntitySlices.copyEntities (.../level/entity/ChunkEntitySlices.java, ~line 104):

final ListTag entitiesInto = into.getListOrEmpty("Entities");
into.put("Entities", entitiesInto);
entitiesInto.addAll(0, entitiesFrom);

A union, not a replace. Each discard-then-regenerate cycle writes on-disk ∪ newly-generated. The existing comment above this branch ("if we're a transient chunk, we cannot save until unloading because otherwise a double save will result in double adding the entities") shows the double-save hazard for one generation is known and guarded against — but nothing guards against repeated generation of the same chunk between saves, which is exactly what a plugin discarding and later re-requesting a chunk produces.

Steps/models to reproduce

Minimal shape, no plugin logic beyond the two calls:

  1. Request a chunk that hasn't been generated yet via the async getChunkAtAsync(x, z, true) (or equivalent), so it goes through full worldgen including mob spawning.
  2. Once loaded, call world.unloadChunk(x, z, false).
  3. Repeat steps 1–2 for the same chunk coordinate a few times (each repeat forces a fresh generation, since nothing was ever written to region/).
  4. Inspect entities/r.<region>.mca for that chunk. Expect one copy of every worldgen-spawned mob per repeat, all sharing Paper.Origin and attribute values, each with a distinct UUID and Spigot.ticksLived = 0.

I validated this at production scale with both a real plugin driving the cycle and a minimal POC.

Plugin and Datapack List

Reproducible without any plugin logic beyond direct API calls (see above). Originally surfaced via Distant Horizons Support (a third-party LOD-generation plugin); not specific to it.

Paper version

This server is running Paper version 26.2-112-main@c9e894d (2026-08-11T05:18:39Z) (Implementing API version 26.2.build.112-stable)

Production incident occurred on 26.2-63-main@3a55f62.

Other

Suggested remedy: either honor save=false for entity data the same way it's honored for terrain, or document clearly that it covers terrain only and provide a real way to express "discard this chunk's entity changes" — the current behavior is worse than either alternative, since it looks like it works.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start at CraftWorld.unloadChunk0 in paper-server/src/main/java/org/bukkit/craftbukkit/CraftWorld.java, then trace NewChunkHolder.unloadStage2 and saveEntities, including ChunkEntitySlices.copyEntities. Reproduce repeated getChunkAtAsync and unloadChunk(x, z, false) cycles, then inspect the relevant entities/r..mca file. Done means discard-style unloads do not persist entity changes or accumulate regenerated entities.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
backend, databases
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
48/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.