Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chunk storage

Verified against Minecraft 26.2 · Part IV · A chunk nobody needs any more is dropped from the world and written to disk, and the server thread never waits for it.

You walk away from your base. Soon after, the chunk you were standing in is no longer reachable from any ticket, its loading level climbs past ChunkLevel.MAX_LEVEL, and a queued task takes a snapshot of it, hands that to a worker to turn into NBT, and hands that to a lane that compresses it and finds it somewhere to live in r.X.Z.mca. Nothing about that is surprising. What is surprising is that the chunk was almost certainly written several times before you left, and that neither of those writes was anybody’s idea. A chunk you keep changing is written by a background sweep roughly every ten seconds — ChunkMap.saveChunksEagerly, at most ChunkMap.CHUNK_SAVED_EAGERLY_PER_TICK (20) chunks a tick, only while fewer than ChunkMap.MAX_ACTIVE_CHUNK_WRITES (128) writes are in flight, each chunk no sooner than ChunkMap.EAGER_CHUNK_SAVE_COOLDOWN_IN_MILLIS (10 000 ms) after its last — and the autosave everyone thinks of as the save is five minutes of wall clock whatever /tick rate says. Almost every write of your world is one nobody asked for.

The cast

classwhat it decidesthread
ChunkMapwhich chunks are dirty, at which of four moments each is written, and whether a half-generated chunk may overwrite a finished one — it is the region/ store, because it extends SimpleRegionStorageServer
SerializableChunkDatathe chunk file as a record: what gets copied while the world is frozen and what gets encoded afterServer copies, a Worker-Main-n encodes
IOWorkerone store’s single lane, and the write-behind map that lets a read answer from a write that has not landedany IO-Worker-n, one task at a time
RegionFileStoragewhich r.X.Z.mca files are open — an LRU of RegionFileStorage.MAX_CACHE_SIZE (256)the IO lane
RegionFilethe sector allocator and the two header tables of one 32×32-chunk file, and the order the bytes land inthe IO lane
EntityStoragethe entities/ store: what a chunk’s mobs cost to write, and that they are rebuilt on the server threadServer builds and parses, IO lane writes
SectionStoragethe poi/ store under PoiManager: which sections are dirty, and the one load that blocksServer
MinecraftServerwhen the next autosave falls, and whether every region file is opened with DSYNCServer

Copy on the server, encode on a worker, write on the IO lane

flowchart LR
    S["Server thread — ChunkMap.save decides, SerializableChunkData.copyOf takes the snapshot"] --> W["Worker-Main-n — SerializableChunkData.write builds the CompoundTag"]
    W --> F["IOWorker.store, foreground priority — joins the encode and parks the tag in pendingWrites"]
    F --> B["IOWorker.storePendingChunk, background priority — runs only when the lane has no foreground work"]
    B --> R["RegionFileStorage.write — compress through RegionFileVersion, place the sectors with RegionFile.write"]
    R --> D["r.X.Z.mca"]

That figure is the page’s answer to why doesn’t saving lag the server. Apart from flushing the position’s POI section, the server thread’s whole share of a save is the middle of ChunkMap.save: SerializableChunkData.copyOf, which copies every LevelChunkSection with LevelChunkSection.copy and each non-empty block and sky DataLayer out of LevelLightEngine.getLayerListener, clones the heightmaps the chunk’s persisted status calls for, pulls block-entity NBT through ChunkAccess.getBlockEntityNbtForSaving, packs the ticks through ChunkAccess.getTicksForSerialization, and packs the structure starts. Everything after that — the palette codecs, the deflate, the sector arithmetic, the syscall — runs somewhere else, and ChunkMap.save returns as soon as the copy is done.

Three folders, and the one thing that is not in region/

LevelStorageSource.LevelStorageAccess.getDimensionPath defers to DimensionType.getStorageFolder, which puts every dimension — the overworld included — under dimensions/<namespace>/<path>/ in the world folder. Inside are region/, entities/, poi/ and data/. The first three are region stores of the same shape — a folder of r.X.Z.mca files, a RegionFileStorage, an IOWorker, and a RegionStorageInfo naming the store (chunk, entities or poi) so that MinecraftServer.reportChunkSaveFailure can say which one broke. Only chunk belongs to ChunkMap itself; EntityStorage and SectionStorage each hold a SimpleRegionStorage rather than being one. data/ is not a region store at all — it is SavedDataStorage, on its own page.

A LevelChunk’s entities are not in region/. The SerializableChunkData.entities list is written only when the chunk’s persisted status is a ChunkType.PROTOCHUNK — worldgen’s spawns, waiting for the column to become full — and SerializableChunkData.carvingMask goes the same way. A full chunk’s entities live in entities/, one file per chunk, holding a Position and an Entities list. If an old save still has entities inside a full chunk’s region/ entry, ServerLevel.addLegacyChunkEntities adopts them on load.

The four moments a chunk is written

the momentwhat runs itwhich chunkswhat holds it back
an unloadthe task ChunkMap.scheduleUnload queued, drained by ChunkMap.processUnloadsthe one chunk being dropped, at whatever status it reachednothing — no cooldown, and whatever the queue holds beyond 2000 tasks drains regardless of the tick budget
the eager sweepChunkMap.saveChunksEagerly, the last statement of that same ChunkMap.processUnloadseverything in ChunkMap.chunksToEagerlySave20 a tick, fewer than 128 writes outstanding, the tick’s time budget, and ten seconds per chunk
an autosaveMinecraftServer.autoSaveServerLevel.saveServerChunkCache.save without flushevery holder in ChunkMap.visibleChunkMaponly the per-chunk gates: ChunkMap.saveAllChunks clears ChunkMap.nextChunkSaveTime, but ChunkMap.saveChunkIfNeeded still wants an accessible, ready, unsaved LevelChunk or ImposterProtoChunk
a flush save/save-all flush, /stop, ServerChunkCache.closeevery accessible holder, over and over until a pass saves noneit blocks the server thread instead

The dirty set behind the second row is narrower than it looks. ChunkMap.setChunkUnsaved is installed as WorldGenContext.unsavedListener and handed to a chunk by ChunkStatusTasks at the moment it becomes full, and LevelChunk.markUnsaved fires that listener only on the false→true edge. So ChunkMap.chunksToEagerlySave holds full chunks that have changed since their last write, each added once, and a chunk still being generated is never in it.

Turning saving off is not as total as it sounds. ChunkMap.tick ticks PoiManager first and unconditionally, and only then asks ServerLevel.noSave whether to run ChunkMap.processUnloads — so a no-save world still writes village data through SectionStorage.tick, and stops letting go of chunks until something forces the issue. The only other drain of ChunkMap.unloadQueue and ChunkMap.toDrop is inside ChunkMap.saveAllChunks with flush, which runs ChunkMap.processUnloads on an always-true budget — so /save-all flush and shutdown do unload them, the first because MinecraftServer.saveAllChunks suppresses ServerLevel.noSave when force is set and the second because ServerChunkCache.close never consults it at all. An explicit save is a different question again: MinecraftServer.saveAllChunks passes ServerLevel.noSave on to ServerLevel.save only when its force flag is clear, and /save-all sets that flag while MinecraftServer.autoSave does not.

A chunk nobody needs any more

sequenceDiagram
    participant DM as DistanceManager
    participant CM as ChunkMap
    participant CH as ChunkHolder
    participant SCD as SerializableChunkData
    participant IOW as IOWorker
    participant SL as ServerLevel
    participant PESM as PersistentEntitySectionManager

    DM->>CM: the level climbs past ChunkLevel.MAX_LEVEL, updateChunkScheduling adds the key to toDrop
    Note over CM: a later tick, in ServerChunkCache.tick's unload phase
    CM->>CM: processUnloads moves the holder from updatingChunkMap to pendingUnloads
    CM->>CH: scheduleUnload reads getSaveSyncFuture and hangs the unload task off it
    CH-->>CM: the future completes, so the task is appended to unloadQueue
    Note over CM: a later tick again, while the tick budget still says yes
    CM->>CH: is getSaveSyncFuture still the same future — if not, scheduleUnload rearms on the new one
    CM->>CM: pendingUnloads.remove of this exact holder — false if a ticket re-adopted it, and the task ends
    CM->>CM: setLoaded false, then save — PoiManager.flush, tryMarkSaved, the proto-over-full guard
    CM->>SCD: copyOf takes the snapshot, and a Worker-Main-n turns it into a CompoundTag
    CM->>IOW: ChunkMap.write hands that encode future to IOWorker.store on the chunk lane
    CM->>SL: ServerLevel.unload clears the block entities and the tick containers, then ThreadedLevelLightEngine drops the layers
    SL->>PESM: later in the same level tick, processUnloads, then EntityStorage.storeEntities on the entities lane
    IOW-->>CM: PendingStore.result completes, activeChunkWrites goes back down

Three things there are load-bearing. The first is that nothing happens until ChunkHolder.saveSync is done: every promotion future is chained into it by ChunkHolder.addSaveDependency, and so is GenerationChunkHolder.generationSaveSyncFuture for as long as a generation step holds a reference, so a chunk mid-promotion or mid-generation cannot be saved or unloaded at all.

The second is the guard. ChunkMap.pendingUnloads is removed by identity: if a ticket re-adopted the position while the task waited, ChunkMap.updateChunkScheduling has already pulled the holder back out of that map, the removal fails, and the task quietly does nothing — nothing is lost and nothing is written twice. And if the sync future changed while waiting, the task rearms itself on the new one rather than proceeding.

The third is that entities go by a different road and a later step. PersistentEntitySectionManager.updateChunkStatus saw the same level change and queued the position in PersistentEntitySectionManager.chunksToUnload. If the chunk’s entity file is still being read, PersistentEntitySectionManager.storeChunkSections returns false and the whole thing is retried next tick, so a half-loaded set never clobbers the file. Otherwise each entity EntityAccess.shouldBeSaved accepts is serialised with Entity.save on the server thread, the tag goes to the entities lane, and those entities are removed with Entity.RemovalReason.UNLOADED_TO_CHUNK. The filter runs before the removal, not after it, so what it turns away — a Player, an EnderDragonPart, a passenger, a vehicle carrying exactly one player — is neither written nor removed.

Two other things leave with the chunk, both after the snapshot is taken: ServerLevel.unload clears its block entities and unregisters its tick containers, and ThreadedLevelLightEngine.updateChunkStatus queues the light engine to forget its layers (lighting).

Why the server thread never waits, and the three times it does

IOWorker is not a thread. It holds a PriorityConsecutiveExecutor over Util.ioPool — a cached pool whose threads are named IO-Worker-n — and its guarantee is that one task at a time runs for that store, not that the same thread runs them. Its three priorities are strictly ordered: IOWorker.Priority.FOREGROUND for IOWorker.store and IOWorker.loadAsync, IOWorker.Priority.BACKGROUND for IOWorker.storePendingChunk — the task that actually touches the disk — and IOWorker.Priority.SHUTDOWN last. The lowest priority has exactly one user in the whole game: the barrier IOWorker.waitForShutdown parks behind everything else when the store closes. A flush is not one of them — IOWorker.synchronize submits it at foreground priority like a store — but it still lands behind the writes, because before it flushes it waits on every IOWorker.PendingStore future, and those complete only when the background tasks have run.

IOWorker.pendingWrites is what that buys. It is a sequenced map from ChunkPos to IOWorker.PendingStore, and a second store for a position already in it overwrites that entry’s data in place without moving it, so N saves of one chunk before the lane drains become one disk write and one shared future. IOWorker.loadAsync looks in the same map first and returns a copy of the pending tag, so a chunk unloaded and re-loaded a second later never touches the region file — read-your-writes by lane order rather than by any lock. IOWorker.STORE_EMPTY is the null supplier that means delete, and IOWorker.scanChunk is the streaming ChunkScanAccess that StructureCheck uses to peek into chunks nobody has loaded.

Three places do make the server thread wait on a disk. ChunkMap.isExistingChunkFull, the guard that stops a ProtoChunk overwriting a finished chunk, answers from ChunkMap.chunkTypeCache when it can but joins the read future inline on a cold entry — the IO lane, then a datafix pass on the worker pool. And SectionStorage.getOrLoad joins too, for a POI section that SectionStorage.prefetch never fetched. The third is not a chunk-storage method at all: StructureCheck.tryLoadFromStorage joins IOWorker.scanChunk to peek at a chunk it will not load, which is what an eye of ender, a dolphin, an explorer map and /locate all end up doing on the server thread. None of the three is on the save path, which is why the save path costs a copy.

Inside a region file

flowchart TD
    A["IOWorker.storePendingChunk pops the oldest entry of pendingWrites"] --> B["RegionFileStorage.getRegionFile, an LRU of 256 open files"]
    B --> C["RegionFile.getChunkDataOutputStream wraps a ChunkBuffer in the selected compressor, NbtIo writes into it"]
    C --> D["closing the buffer back-patches the length and calls RegionFile.write"]
    D --> E{"how many sectors"}
    E -- "under 256" --> F1["RegionBitmap.allocate takes the first free run"]
    F1 --> F2["the compressed chunk is written to those new sectors"]
    F2 --> F3["offsets and timestamps updated, then RegionFile.writeHeader"]
    F3 --> F4["any stale sidecar for this chunk is deleted"]
    F4 --> Z["and only now are the old sectors freed"]
    E -- "256 or more" --> G1["one sector is allocated for a stub"]
    G1 --> G2["the payload goes to a temp file in the same folder, and a five-byte stub with EXTERNAL_STREAM_FLAG is written to that sector"]
    G2 --> G3["offsets and timestamps updated, then RegionFile.writeHeader"]
    G3 --> G4["the temp file is moved onto c.X.Z.mcc, over the previous copy"]
    G4 --> Z

A RegionFile is one r.X.Z.mca: two header sectors (RegionFile.SECTOR_BYTES is 4096) holding a 1024-entry offset table, RegionFile.offsets, packed as sector number ≪ 8 with the sector count in the low byte, and a 1024-entry RegionFile.timestamps. Free space is a RegionBitmap, with the header’s two sectors forced used at construction and RegionBitmap.allocate handing out the first run big enough. Each stored chunk starts with RegionFile.CHUNK_HEADER_SIZE (5) bytes — a length and a compression id — and both RegionFile.write and RegionFile.getChunkDataInputStream are synchronised on the RegionFile itself rather than on the channel, though in practice only one lane ever drives a given folder.

Read the two branches of the figure against each other and the page’s best fact falls out. For an ordinary chunk the new bytes are on disk before the header points at them, and the old bytes are released after — so a crash at any point leaves either the old chunk or the new one, and a chunk never overwrites itself in place. For an oversized chunk the ordering is reversed. Anything needing RegionFile.EXTERNAL_CHUNK_THRESHOLD (256) sectors or more cannot be described by an eight-bit count field at all, so it goes to a .mcc sidecar and the region file keeps only a stub carrying RegionFile.EXTERNAL_STREAM_FLAG; and the sidecar is moved into place after RegionFile.writeHeader has already committed the pointer to it, destroying the previous copy at a fixed path. The in-file case is content-then-pointer. The sidecar case is pointer-then-content. Either way the ordering only buys anything if the writes reach the platter in that order, which is what DSYNC is for: MinecraftServer.forceSynchronousWrites returns true as the base default, and two of the three servers override it — DedicatedServer from DedicatedServerProperties.syncChunkWrites (sync-chunk-writes, default true) and IntegratedServer from Options.syncWrites, whose default is true only on Windows. GameTestServer keeps the base answer.

The compression byte is per chunk, not per file. RegionFileVersion.selected — set once by RegionFileVersion.configure from DedicatedServerProperties.regionFileComression (Mojang’s spelling; the property is region-file-compression) — decides only what new writes use, choosing between RegionFileVersion.VERSION_DEFLATE (the RegionFileVersion.DEFAULT), RegionFileVersion.VERSION_NONE and RegionFileVersion.VERSION_LZ4. Reads honour whatever byte each chunk carries, including RegionFileVersion.VERSION_GZIP, which has no option name and so can be read but never chosen, and RegionFileVersion.VERSION_CUSTOM, which exists so that RegionFile.createChunkInputStream can recognise it and refuse.

The way back in

Loading is the same road driven backwards, and it changes hands four times. ChunkMap.scheduleChunkLoad starts with IOWorker.loadAsync on the IO lane; ChunkMap.readChunk then hops to Util.backgroundExecutor under the name upgradeChunk for SimpleRegionStorage.upgradeChunkTag, which is where datafixing happens; SerializableChunkData.parse runs on the same pool under parseChunk; and SerializableChunkData.read runs on the server thread, where the sections are installed, the saved light is queued into the light engine, and PoiManager.checkConsistencyWithBlocks re-derives each section’s points of interest from its blocks. Running beside all of it, SectionStorage.prefetch pulls the POI file in, and the two are joined before the server-thread step — which is exactly why that step’s SectionStorage.getOrLoad calls do not block. From there the generation pipeline takes over.

Entities come back the same shape but land differently: EntityStorage schedules both the datafix and EntityType.loadEntitiesRecursive on EntityStorage.entityDeserializerQueue, a ConsecutiveExecutor over the server main-thread executor, so only the NBT read is off-thread.

Questions players ask

Does the game stall when it saves? Only on a flush. ChunkMap.saveAllChunks with flush loops over the accessible holders, blocking the main-thread executor on each ChunkHolder.isReadyForSaving until a whole pass saves nothing, then flushes POIs with SectionStorage.flushAll, runs ChunkMap.processUnloads with an always-true budget, and finally joins IOWorker.synchronize with flush. That is the only place where waiting for the disk is the point rather than an accident, and it is what /save-all flush and /stop do.

Why does lowering the tick rate not push out my autosave? Because the interval is wall clock. MinecraftServer.computeNextAutosaveInterval is the tick rate times 300 — or, while the server is sprinting, 300 times the rate its recent tick times imply — floored at MinecraftServer.MIMINUM_AUTOSAVE_TICKS (100 — the typo is Mojang’s); the very first interval is MinecraftServer.AUTOSAVE_INTERVAL (6000 ticks). MinecraftServer.onTickRateChanged recomputes it on every /tick rate, but assigns the result only when it is smaller than the pending countdown, so changing the rate can bring the next autosave forward and can never push it back. The server tick has the rest of that loop.

Can a half-generated chunk overwrite my base? The guard is best effort. ChunkMap.save refuses to write a non-full chunk over a full one on disk — but ChunkMap.isExistingChunkFull returns false, meaning go ahead, whenever the read throws or comes back empty, so an IO error licenses exactly the clobber the guard exists to prevent. Worse, ChunkAccess.tryMarkSaved clears the unsaved flag before the guards run, so a chunk the guard turns away has already been marked clean and will not be offered again. A proto chunk still at ChunkStatus.EMPTY with no valid structure start is dropped by the same block, and an ImposterProtoChunk never reaches any of it: ImposterProtoChunk.tryMarkSaved and ImposterProtoChunk.canBeSerialized both answer false — not because the wrapper defers to the LevelChunk it wraps, which only ImposterProtoChunk.markUnsaved does, but because it refuses to be serialised at all.

Why is my entities/ folder full of files with nothing in them? It is not — but emptying a chunk costs one write. EntityStorage.storeEntities with an empty set only writes when EntityStorage.emptyChunks did not already contain the position, and that write is IOWorker.STORE_EMPTY, which zeroes the region entry and deletes any sidecar. The first time a chunk goes empty costs a write. Every later save of it costs nothing.

Do the file timestamps mean anything? Not to the game. RegionFile.write stamps each entry with epoch seconds from RegionFile.getTimestamp, which reads Util.getEpochMillis, and nothing ever reads the table back; the save cooldown in ChunkMap.nextChunkSaveTime is monotonic Util.getMillis. Two clocks, and neither of them is game time.

Where to look

ChunkMap.tick · ChunkMap.processUnloads · ChunkMap.scheduleUnload · ChunkMap.save · ChunkMap.saveChunksEagerly · ChunkMap.saveChunkIfNeeded · ChunkMap.saveAllChunks · SerializableChunkData.copyOf · SerializableChunkData.write · IOWorker.store · IOWorker.storePendingChunk · IOWorker.loadAsync · RegionFileStorage.write · RegionFile.write · RegionBitmap.allocate · PersistentEntitySectionManager.storeChunkSections · EntityStorage.storeEntities · SectionStorage.writeChunk · SectionStorage.prefetch · ChunkMap.scheduleChunkLoad · SerializableChunkData.read · MinecraftServer.computeNextAutosaveInterval · DimensionType.getStorageFolder

Next door: tickets and loading raises the level, chunk anatomy owns what LevelChunkSection.copy copies, lighting owns the layers the unload throws away, points of interest owns the poi/ store, the server tick owns the budget every method here is handed, how a server dies is the save that does not happen, and entity lifecycle is what Entity.RemovalReason.UNLOADED_TO_CHUNK means to a mob.


Rules: names, never code · how the system works, not how the code reads · newest version only · every backticked name passes tools/verify_names.py.