SavedDataStorage#tryWrite writes straight into the live file. NbtIo.writeCompressed(tag, path) opens it with SYNC, WRITE, CREATE, TRUNCATE_EXISTING, so from the open until the last flush the file on disk is empty or partial. If the process dies in between (crash, OOM kill, power loss, kill -9, a panel's "force stop") it stays that way. On the next start readSavedData logs Error loading saved data, computeIfAbsent builds defaults, and the next save writes those over the broken file.
That's every file under data/: scoreboard.dat, game_rules.dat, world_border.dat, weather.dat, world_clocks.dat, raids.dat, ender_dragon_fight.dat, chunk_tickets.dat, maps, command storage and the rest.
level.dat and playerdata got a temp file and a rename for exactly this in 1.20.3 (MC-193198), SavedDataStorage didn't. It's not MC-115237 either, that one is about region files, level.dat and player data.
Steps to reproduce
The window is too short to hit by hand, so there's a script attached. It types console commands into a stock server, polls stat() on the files and kills the process.
python3 saveddata_repro.py --accept-eula torn-writeFresh 26.3 server. 200 scoreboard objectives with 30 KB display names, so scoreboard.dat is about 4 MB. That's only there to make the window long enough to hit on the first try.
save-all flushAdd one objective,
save-all, SIGKILL as soon as scoreboard.dat shrinks.Start the server again,
scoreboard objectives list,stop.
Expected result
The objectives are still there. An interrupted save leaves the previous file alone, like it does for level.dat and playerdata.
Actual result
scoreboard.dat is 0 bytes after the kill. On the next start:
[Server thread/ERROR]: Error loading saved data: SavedDataType[minecraft:scoreboard]
java.lang.ArrayIndexOutOfBoundsException: arraycopy: length -1 is negative
at java.base/java.io.PushbackInputStream.unread(PushbackInputStream.java:235)
at net.minecraft.world.level.storage.SavedDataStorage.isGzip(SavedDataStorage.java:138)
at net.minecraft.world.level.storage.SavedDataStorage.readTagFromDisk(SavedDataStorage.java:114)
at net.minecraft.world.level.storage.SavedDataStorage.readSavedData(SavedDataStorage.java:91)
...
[Server thread/INFO]: System chat: There are no objectives(The AIOOBE is just how isGzip reacts to a 0 byte file.)
After stop, scoreboard.dat is a valid 48 byte file with nothing in it.
In the Linux run the same kill also got weather.dat and world_clocks.dat:
[ServerMain/ERROR]: Error loading saved data: SavedDataType[minecraft:weather]
[ServerMain/ERROR]: Error loading saved data: SavedDataType[minecraft:world_clocks]Hit on the first attempt on Linux 6.10 / ext4 and on macOS 27 / APFS. Full output is attached.
Code analysis
// SavedDataStorage
private void tryWrite(final SavedDataType<?> type, final CompoundTag tag) {
Path path = this.getDataFile(type.id());
try {
FileUtil.createDirectoriesSafe(path.getParent());
NbtIo.writeCompressed(tag, path);
} catch (IOException e) {
LOGGER.error("Could not save data to {}", path.getFileName(), e);
}
}
// NbtIo
private static final OpenOption[] SYNC_OUTPUT_OPTIONS = new OpenOption[]{
StandardOpenOption.SYNC, StandardOpenOption.WRITE, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING
};The save right before the kill, both writers next to each other (torn-write-linux.txt):
world/data/minecraft/scoreboard.dat start=4161553 min_seen=7690 end=4161573 size_changes=357 inodes=1 IN PLACE (same inode) window=363.9ms
world/level.dat start=397 min_seen=397 end=400 size_changes=1 inodes=2 REPLACED (new inode) window=0.0msopenat(AT_FDCWD, "./world/level103001950465854517.dat", O_WRONLY|O_CREAT|O_TRUNC|O_SYNC, 0666) = 115
renameat(AT_FDCWD, "./world/level.dat", AT_FDCWD, "./world/level.dat_old") = 0
renameat(AT_FDCWD, "./world/level103001950465854517.dat", AT_FDCWD, "./world/level.dat") = 0
openat(AT_FDCWD, "./world/data/minecraft/scoreboard.dat", O_WRONLY|O_CREAT|O_TRUNC|O_SYNC, 0666) = 117PlayerDataStorage#save already does Files.createTempFile, NbtIo.writeCompressed(tag, tmpFile), Util.safeReplaceFile. The same thing would work here.
A few notes:
All dirty files are written at the same time on the IO pool, which is why one kill can take several.
weather.dat and world_clocks.dat are rewritten on every save, so there's always something in the window. The big scoreboard is only for the repro.
SYNCmakes the window longer. The stream is aBufferedOutputStream, so every 8 KB is its own synchronous write.I think this is what's behind MC-132848 (scoreboard.dat wiped, open since 2018, no repro). MC-147504 and MC-142036 look like the same thing after a power loss, both got closed as Invalid.
Environment
Dedicated server. Linux 6.10 (ext4) with Temurin 25.0.4, macOS 27 (APFS) with Temurin 25.0.3.
Attachments
Comments 3
Two related reports I filed after this one:
MC-312009 (a failed write is never retried): https://bugs.mojang.com/browse/MC/issues/MC-312009
MC-312010 (chunk_tickets.dat rewritten on every save): https://bugs.mojang.com/browse/MC/issues/MC-312010
Thank you for helping us improve Minecraft! We saved your files: